TypeScript React + Generics Part I — Time-Saving Patterns
Andrew Ross
Posted on June 13, 2023
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>
>;
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]; }
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>>;
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>
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>
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>;
};
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;
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>;
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;
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>
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;
For context, the React.DetailedHTMLProps
type has the following definition
type DetailedHTMLProps<E extends React.HTMLAttributes<T>, T> =
React.ClassAttributes<T> & E
In practice, this looks something like
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
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
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];
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>
);
}
Intellisense when hovering above the spread ...props
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
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>;
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>
);
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>
>;
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.
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>
);
}
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;
}
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
Posted on June 13, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.