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





Top comments (0)