DEV Community

Cover image for TypeScript React + Generics Part I — Time-Saving Patterns
Andrew Ross
Andrew Ross

Posted on • Updated on

TypeScript React + Generics Part I — Time-Saving Patterns

Time is Money

Using TSX + Generics strategically in any given project helps to establish clean coding patterns and ultimately save time in the long run. I wrote the following definitions earlier this afternoon after becoming frustrated with unorganized typedefs littering a plethora of files across a project codebase. The defs in question happened to be mostly imports from React of the Attribute-Element variety. That said, let's begin

import type React from "react";

export type RemoveFields<T, P extends keyof T = keyof T> = {
  [S in keyof T as Exclude<S, P>]: T[S];
};

export type ExtractPropsTargeted<T> = T extends 
  React.DetailedHTMLProps<infer U, Element>
  ? U
  : T;

export type PropsTargeted<
  T extends keyof globalThis.JSX.IntrinsicElements = 
  keyof globalThis.JSX.IntrinsicElements
> = {
  [P in T]: ExtractPropsTargeted<
    globalThis.JSX.IntrinsicElements[P]
  >;
}[T];

export type PropsExcludeTargeted<
  T extends keyof globalThis.JSX.IntrinsicElements = 
  keyof globalThis.JSX.IntrinsicElements,
  J extends keyof PropsTargeted<T> = 
  keyof PropsTargeted<T>
> = RemoveFields<PropsTargeted<T>, J>;

export type PropsIncludeTargeted<
  T extends keyof globalThis.JSX.IntrinsicElements = 
  keyof globalThis.JSX.IntrinsicElements,
  J extends keyof PropsTargeted<T> = 
  keyof PropsTargeted<T>
> = RemoveFields<
      PropsTargeted<T>, 
      Exclude<keyof PropsTargeted<T>, J>
    >;

Enter fullscreen mode Exit fullscreen mode

The RemoveFields<T, keyof T> type

Why bother writing our own RemoveFields helper when we can use the builtin Omit type? Because it is a stricter implementation of Omit; it also provides heightened intellisense

Let's take a look at the definitions for Omit and RemoveFields side-by-side:

type Omit<T, K extends string | number | symbol> = 
{ [P in Exclude<keyof T, K>]: T[P]; }

type RemoveFields<T, P extends keyof T = keyof T> = 
{ [S in keyof T as Exclude<S, P>]: T[S]; }

Enter fullscreen mode Exit fullscreen mode

While the difference may seem trivial at first glance, a real-world example involving the mapping of props in the Nextjs Link Component should provide a clearer distinction

The Link Component in next/link/client/link.d.ts is defined as

declare const Link: React.ForwardRefExoticComponent<Omit<
  React.AnchorHTMLAttributes<HTMLAnchorElement>, 
  keyof InternalLinkProps
> & InternalLinkProps & {
    children?: React.ReactNode;
} & React.RefAttributes<HTMLAnchorElement>>;
Enter fullscreen mode Exit fullscreen mode

However, we're only interested in the definitions within the external React.ForwardRefExoticComponent<P> wrapper, so we can simplify the typedef as follows

type TargetedLinkProps = Omit<
  React.AnchorHTMLAttributes<HTMLAnchorElement>, 
  keyof InternalLinkProps
> & InternalLinkProps & {
    children?: React.ReactNode;
} & React.RefAttributes<HTMLAnchorElement>
Enter fullscreen mode Exit fullscreen mode

With Omit in place in the TargetedLinkProps type and with the strict and always-strict flags turned on in our tsconfig.json file, there are no type errors in the above definition. However, if we swap Omit out in favor of RemoveFields we see errors appear immediately.

// replacing `Omit` with `RemoveFields` results in errors

type TargetedLinkProps = RemoveFields<
  React.AnchorHTMLAttributes<HTMLAnchorElement>, 
  keyof InternalLinkProps
> & InternalLinkProps & {
    children?: React.ReactNode;
} & React.RefAttributes<HTMLAnchorElement>
Enter fullscreen mode Exit fullscreen mode

Why? Omit is forgiving or lenient when it comes to key-discrimination and allows for extraneous keys to be present that do not exist in a given type definition which is a double-edged sword. If precision is your aim, RemoveFields can simply be thought of as a souped-up ever-vigilant version of Omit.

So what about these errors? Not to worry, these errors can be remedied by dissecting the definition for InternalLinkProps and refactoring appropriately

InternalLinkProps = LinkProps

Nextjs exports a LinkProps type from next/link which has a 1:1 relationship with the InternalLinkProps type. The definition of LinkProps is as follows

type InternalLinkProps = {
    href: Url;
    as?: Url;
    replace?: boolean;
    scroll?: boolean;
    shallow?: boolean;
    passHref?: boolean;
    prefetch?: boolean;
    locale?: string | false;
    legacyBehavior?: boolean;
    onMouseEnter?: React.MouseEventHandler<HTMLAnchorElement>;
    onTouchStart?: React.TouchEventHandler<HTMLAnchorElement>;
    onClick?: React.MouseEventHandler<HTMLAnchorElement>;
};
Enter fullscreen mode Exit fullscreen mode

For absolute clarity, I've included the type definitions for Url, UrlObject, and ParsedUrlQueryInput below

interface ParsedUrlQueryInput extends NodeJS.Dict<
    | string
    | number
    | boolean
    | ReadonlyArray<string>
    | ReadonlyArray<number>
    | ReadonlyArray<boolean>
    | null
>{}

interface UrlObject {
    auth?: string | null | undefined;
    hash?: string | null | undefined;
    host?: string | null | undefined;
    hostname?: string | null | undefined;
    href?: string | null | undefined;
    pathname?: string | null | undefined;
    protocol?: string | null | undefined;
    search?: string | null | undefined;
    slashes?: boolean | null | undefined;
    port?: string | number | null | undefined;
    query?: string | null | ParsedUrlQueryInput | undefined;
}

type Url = string | UrlObject;
Enter fullscreen mode Exit fullscreen mode

What exactly is causing the error?

If we cross-compare all 14 keys within the InternalLinkProps type with all keys defined within the React.AnchorHTMLAttributes<HTMLAnchorElement> entity (which Omit is acting on), we find that only 4 out of 14 keys in keyof InternalLinkProps match keys within the targeted React.AnchorHTMLAttributes<HTMLAnchorElement> type.

The implication?

The Omit type helper fails to detect that 10 out of 14 keys in the keyof InternalLinkProps argument do not exist at all in the React.AnchorHTMLAttributes<HTMLAnchorElement>. The RemoveFields helper does detect the presence of extraneous keys which alerts us of the mismatch to begin with

With that out of the way, we can rewrite our TargetedLinkProps typedef as follows (only the four matching keys being passed in as arguments)

type TargetedLinkProps = RemoveFields<
  React.AnchorHTMLAttributes<HTMLAnchorElement>,
  "href" | "onClick" | "onMouseEnter" | "onTouchStart"
> &
  InternalLinkProps & {
    children?: React.ReactNode;
  } & React.RefAttributes<HTMLAnchorElement>;
Enter fullscreen mode Exit fullscreen mode

Extractor Types

There are a number of commonly used helper types that unwrap or extract internal types by utilizing TypeScripts infer method

For example

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type Unenumerate<T> = T extends Array<infer U> ? U : T;
Enter fullscreen mode Exit fullscreen mode

In fact, we could have taken this extractor route to derive the types contained within the Nextjs Link Component in the previous section instead of eyeballing it and manually pulling the types out

import Link from "next/link";

type InferReactForwardRefExoticComponentProps<T> = T extends 
  React.ForwardRefExoticComponent<infer U> 
   ? U 
   : T;


/**
 * this is equal to the previously defined 
 * `TargetedLinkProps` type
 */

type ExtractedLinkProps = 
InferReactForwardRefExoticComponentProps<typeof Link>

Enter fullscreen mode Exit fullscreen mode

The ExtractPropsTargeted Extractor Type

This is the second of five core typedefs defined in the very first code block in this post

type ExtractPropsTargeted<T> = T extends 
  React.DetailedHTMLProps<infer U, Element>
  ? U
  : T;
Enter fullscreen mode Exit fullscreen mode

For context, the React.DetailedHTMLProps type has the following definition

type DetailedHTMLProps<E extends React.HTMLAttributes<T>, T> =
 React.ClassAttributes<T> & E
Enter fullscreen mode Exit fullscreen mode

In practice, this looks something like

React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
Enter fullscreen mode Exit fullscreen mode

So what is it that we're trying to pull out exactly? We are targeting the first of two generic arguments within React.DetailedHTMLProps which I usually refer to as the attribute<element> tandem

Using ExtractPropsTargeted within the PropsTargeted Mapper definition

The Second type, ExtractPropsTargeted, is used to extract all attribute-element tandems that exist in React from the recursively mapped globalThis.JSX.IntrinsicElements entity. This entity contains key-value pairs that each have an outer React.DetailedHTMLProps wrapper

IntrinsicElements

As illustrated below, the ExtractPropsTargeted Extractor type wraps the globalThis.JSX.IntrinsicElements entity to effectively derive each Attribute<Element> tandem. The exact tandem pulled out is a function of the key passed in ("div", "a", "p", etc.).


type PropsTargeted<
  T extends keyof globalThis.JSX.IntrinsicElements = 
  keyof globalThis.JSX.IntrinsicElements
> = {
  [P in T]: ExtractPropsTargeted<
    globalThis.JSX.IntrinsicElements[P]
  >;
}[T];

Enter fullscreen mode Exit fullscreen mode

A simple use case for this type is as follows

export function TextField({
  id,
  label,
  type = "text",
  className,
  ...props
}: PropsTargeted<"input"> & { label: ReactNode }) {
  return (
    <div className={className}>
      {label && <Label htmlFor={id}>{label}</Label>}
      <input id={id} type={type} {...props} className={formClasses} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Intellisense when hovering above the spread ...props

PropsTargeted

The Payoff — PropsExcludeTargeted and PropsIncludeTargeted

At last we arrive at the payoff. The first three core types (RemoveFields, ExtractPropsTargeted, and PropsTargeted) have provided the workup necessary for writing these truly intuitive helper types

Nods

The final two of the initial five core types contained within the first code block of this article each take two arguments; (1) the targeted intrinsic element key such as "div" or "svg" or "li" etc and (2) the keys of targeted fields within a given top-level intrinsic element (as defined by (1)). The second argument, a union of keys, serves to either exclude or include targeted fields appropriately.

The PropsExcludeTargeted type

The definition of the PropsExcludeTargeted type is as follows

type PropsExcludeTargeted<
  T extends keyof globalThis.JSX.IntrinsicElements = 
  keyof globalThis.JSX.IntrinsicElements,
  J extends keyof PropsTargeted<T> = 
  keyof PropsTargeted<T>
> = RemoveFields<PropsTargeted<T>, J>;
Enter fullscreen mode Exit fullscreen mode

This is used in practice for example when you have an svg with viewBox, xmlns, and aria-hidden defined in the original component. There's no sense in forwarding props that will do absolutely nothing if populated in a parent component elsewhere.

Therefore, best practice is to preemptively strip known static or unchanging fields from the originating component as follows

const AppleIcon: FC<
  PropsExcludeTargeted<"svg", "viewBox" | "xmlns">
> = ({ fill, ...props }) => (
  <svg
    {...props}
    viewBox='0 0 815 1000'
    fill='none'
    xmlns='http://www.w3.org/2000/svg'>
    <path
      fill={fill ?? "black"}
      d='M788.1 340.9C782.3 345.4 679.9 403.1 679.9 531.4C679.9 679.8 810.2 732.3 814.1 733.6C813.5 736.8 793.4 805.5 745.4 875.5C702.6 937.1 657.9 998.6 589.9 998.6C521.9 998.6 504.4 959.1 425.9 959.1C349.4 959.1 322.2 999.9 260 999.9C197.8 999.9 154.4 942.9 104.5 872.9C46.7 790.7 0 663 0 541.8C0 347.4 126.4 244.3 250.8 244.3C316.9 244.3 372 287.7 413.5 287.7C453 287.7 514.6 241.7 589.8 241.7C618.3 241.7 720.7 244.3 788.1 340.9ZM554.1 159.4C585.2 122.5 607.2 71.3 607.2 20.1C607.2 13 606.6 5.8 605.3 0C554.7 1.9 494.5 33.7 458.2 75.8C429.7 108.2 403.1 159.4 403.1 211.3C403.1 219.1 404.4 226.9 405 229.4C408.2 230 413.4 230.7 418.6 230.7C464 230.7 521.1 200.3 554.1 159.4Z'
    />
  </svg>
);

Enter fullscreen mode Exit fullscreen mode

While fill is defined as none in the top level svg intrinsic element, you can always pass its prop down into a nested intrinsic element if the opportunity presents itself instead of outright omitting it (such as path in this situation). The fill prop is of type string | undefined in svg and path alike.

My Personal Favorite — PropsIncludeTargeted

The PropsIncludeTargeted type is defined as follows

type PropsIncludeTargeted<
  T extends keyof globalThis.JSX.IntrinsicElements = 
  keyof globalThis.JSX.IntrinsicElements,
  J extends keyof PropsTargeted<T> = 
  keyof PropsTargeted<T>
> = RemoveFields<
      PropsTargeted<T>, 
      Exclude<keyof PropsTargeted<T>, J>
    >;
Enter fullscreen mode Exit fullscreen mode

Why is this type my personal favorite of the five? Because WYSIWYG. The keys passed in to the second argument of the type are the only fields defined — nothing less, nothing more.

refreshing

Consider the following simple example with a reusable Label component

function Label({
  htmlFor: id,
  children
}: PropsIncludeTargeted<"label", "htmlFor" | "children">) {
  return (
    <label
      htmlFor={id}
      className='mb-2 block text-sm font-semibold text-gray-900'>
      {children}
    </label>
  );
}
Enter fullscreen mode Exit fullscreen mode

The only two fields derived from the targeted LabelHTMLAttributes<HTMLLabelElement> tandem are children and htmlFor as is expected by the key arguments passed in. This results in the following inferred type def

{
    htmlFor?: string | undefined;
    children?: ReactNode;
}
Enter fullscreen mode Exit fullscreen mode

Wrapping it up

This post is the first of ? in a series of posts about how utilizing typescript and generics can streamline and clean up code. In future articles I intend to cover utilizing generics in other contexts such as the filesystem, the api layer, and more. Thanks for reading along, cheers

Wait that is illegal

Top comments (0)