A15 · Part 3 · Advanced
Advanced TypeScript with React
Making the wrong call fail to compile, rather than fail in review.
15On this page
- Polymorphic components
- Mutually exclusive props
- Requiring a label, one way or the other
- Typing a generic list component
- Constraining the generic instead of asking for a function
- Compound components with one import
- Stopping one prop from deciding the type
- Deriving types instead of restating them
- Lookup tables that cannot miss a case
- Exhaustiveness
- IDs that cannot be swapped
- Typing context so consumers get a non-null value
- Typing custom hooks
- Typing an action's result
- Discipline that matters more than any trick
Polymorphic components
type BoxProps<E extends React.ElementType> = {
as?: E;
children?: React.ReactNode;
} & Omit<React.ComponentPropsWithRef<E>, "as" | "children">;
function Box<E extends React.ElementType = "div">({ as, ...rest }: BoxProps<E>) {
const Component = as ?? "div";
return <Component {...rest} />;
}
<Box as="a" href="/x" /> // ✓ href allowed
<Box as="div" href="/x" /> // ✗ compile error
One component that can render as any element, with the correct props for
whichever it becomes. This is what as props in design systems are
built on, and the Omit is what stops your own props colliding with
the element's. Using ComponentPropsWithRef means the
ref type follows the element too: a ref on
<Box as="a"> must be an anchor ref. Since React 19 the ref
simply travels in rest.
This is also where the pattern's cost shows. Polymorphic types slow the
checker and produce long error messages. Many design systems now prefer an
asChild prop that merges props onto the single child, which needs
far less typing.
Mutually exclusive props
type Props =
| { variant: "link"; href: string; onClick?: never }
| { variant: "button"; onClick: () => void; href?: never };
<Action variant="link" href="/x" /> // ✓
<Action variant="link" onClick={fn} /> // ✗ compile error
A union with never on the excluded members turns "these two props
should never appear together" from a code-review comment into a compile error.
Requiring a label, one way or the other
type Labelled =
| { children: React.ReactNode; "aria-label"?: never }
| { children?: never; "aria-label": string };
type IconButtonProps = React.ComponentProps<"button"> & Labelled;
Now an icon-only button with no accessible name does not compile. This is the most valuable use of the technique: encoding an accessibility rule in the type so it cannot be forgotten.
Typing a generic list component
type ListProps<T> = {
items: readonly T[];
keyOf: (item: T) => React.Key;
children: (item: T, index: number) => React.ReactNode;
};
function List<T>({ items, keyOf, children }: ListProps<T>) {
return <ul>{items.map((item, i) => <li key={keyOf(item)}>{children(item, i)}</li>)}</ul>;
}
<List items={users} keyOf={(u) => u.id}>
{(user) => <span>{user.name}</span>} // user inferred as User
</List>
readonly T[] in the props stops the component mutating the
caller's array, which is a real bug the type can simply prevent.
Constraining the generic instead of asking for a function
function Table<T extends { id: string }>({ rows, columns }: {
rows: readonly T[];
columns: { [K in keyof T]?: (value: T[K], row: T) => React.ReactNode };
}) {
return rows.map((row) => <tr key={row.id}>...</tr>); // id is guaranteed
}
<Table rows={users} columns={{ email: (v) => <a href={"mailto:" + v}>{v}</a> }} />
// v is string because users[number]["email"] is string; a misspelt column key is an error
A constraint says what every item must have, so the component can rely on it
without a keyOf callback. The mapped type does the rest: each
column renderer receives the type of its own field, and a column for a field
that does not exist fails to compile. This is the typing that data-grid
libraries are built on.
Compound components with one import
function TabsRoot(props: TabsProps) { ... }
function TabList(props: React.ComponentProps<"div">) { ... }
function TabPanel(props: { value: string; children: React.ReactNode }) { ... }
export const Tabs = Object.assign(TabsRoot, { List: TabList, Panel: TabPanel });
<Tabs defaultValue="a">
<Tabs.List>...</Tabs.List>
<Tabs.Panel value="a">...</Tabs.Panel>
</Tabs>
Object.assign returns a type that is the function and the
attached parts together, so Tabs.Panel is fully typed with no
manual interface. Assigning TabsRoot.Panel = TabPanel after the
fact also works in TypeScript, but only for a function declaration in the
same file.
Stopping one prop from deciding the type
function Select<T extends string>(props: {
options: readonly T[];
defaultValue: NoInfer<T>;
}) { ... }
<Select options={["sm", "md", "lg"]} defaultValue="xl" />
// ✓ with NoInfer: error — "xl" is not "sm" | "md" | "lg"
// ✗ without it: T widens to include "xl", and the typo compiles
TypeScript infers a generic from every place it appears, so a typo in
defaultValue quietly becomes one of the options. The built-in
NoInfer utility (TypeScript 5.4 and later) marks a position as
"check against T, but do not use me to work out T".
Deriving types instead of restating them
type FormValues = z.infer<typeof schema>; // from the validator
type Post = Awaited<ReturnType<typeof getPost>>; // from the function
type Summary = Pick<Post, "id" | "title">; // from the model
type PostId = Post["id"]; // from the field
type ButtonVariant = React.ComponentProps<typeof Button>["variant"]; // from a component
Every type written twice will eventually disagree with itself. Derive from the one source that is already correct. The last line is useful for a component from a library that does not export its props type.
Lookup tables that cannot miss a case
type Status = "idle" | "loading" | "error" | "ready";
const badge = {
idle: "Not started",
loading: "Working…",
error: "Failed",
ready: "Done",
} satisfies Record<Status, string>;
badge.ready; // type is still the literal object, not a loose Record
satisfies checks the object against a type without replacing the
object's own, more precise type. Add a fifth status and this object fails to
compile until someone writes its label; remove one and the stale key is an
error. It is assertNever for data instead of for code, and it
suits variant maps, icon tables and route definitions.
Exhaustiveness
function assertNever(x: never): never {
throw new Error("Unhandled: " + JSON.stringify(x));
}
switch (state.status) {
case "idle": ...
case "loading": ...
case "ready": ...
default: return assertNever(state); // ✗ compile error if a case is missing
}
Add a fourth status and this line stops compiling, pointing at every switch that needs updating. It is the cheapest safety net in a React codebase with state machines in it.
IDs that cannot be swapped
type UserId = string & { readonly __brand: "UserId" };
type PostId = string & { readonly __brand: "PostId" };
function toUserId(raw: string): UserId {
if (!raw.startsWith("usr_")) throw new Error("Not a user id");
return raw as UserId; // the one place a cast is allowed
}
function followUser(id: UserId) { ... }
followUser(post.id); // ✗ PostId is not UserId
Every id is a string, so passing a post id where a user id belongs compiles and fails at runtime with a confusing 404. A brand is a type-only tag — it adds nothing at runtime — that makes the two incompatible, and confines the cast to a single validating function.
Typing context so consumers get a non-null value
function createStrictContext<T>(name: string) {
const Ctx = createContext<T | null>(null);
function useCtx(): T {
const value = useContext(Ctx);
if (value === null) throw new Error(name + " used outside its provider");
return value; // narrowed to T
}
return [Ctx, useCtx] as const;
}
const [ThemeContext, useTheme] = createStrictContext<Theme>("useTheme");
One helper, and every context in the codebase gets both the runtime guard and
the non-null type. as const is what keeps the tuple's types
distinct rather than collapsing to a union.
Typing custom hooks
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return [on, toggle] as const; // ✓ [boolean, () => void]
} // without it: (boolean | (() => void))[]
Returning a tuple without as const gives an array of a union
type, and destructuring it produces two values that are both possibly either
thing. It is the most common typing mistake in custom hooks.
function useStoredState<T>(key: string, fallback: T, parse: (raw: unknown) => T) {
const [value, setValue] = useState<T>(() => {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
try { return parse(JSON.parse(raw)); } catch { return fallback; }
});
...
}
const [prefs] = useStoredState("prefs", defaultPrefs, PrefsSchema.parse);
A generic hook that reads outside data should take a parser, not trust a type
argument. useStoredState<Prefs>("prefs") would be a cast
in disguise: last month's saved shape arrives typed as this month's
Prefs.
Typing an action's result
type FormState =
| { ok: true }
| { ok: false; fieldErrors: Partial<Record<keyof FormValues, string>> };
async function save(prev: FormState | null, formData: FormData): Promise<FormState> { ... }
const [state, action] = useActionState(save, null);
if (state && !state.ok) state.fieldErrors.title; // narrowed
Server Actions return data across the network, so a discriminated result is
the contract between server and form. keyof FormValues ties the
error keys to the real field names: rename a field and every stale error key
becomes a compile error.
Discipline that matters more than any trick
unknown, notany.anydisables checking for everything downstream of it.- Narrow at the boundary. Parse API responses with a schema; do not assert a shape you have not checked.
@ts-expect-error, not@ts-ignore, with a reason. Both silence the next line, but@ts-expect-errorbecomes an error itself once the underlying problem is fixed, so suppressions cannot outlive their cause.- Stop before it gets clever. A conditional type nobody on the team can read is a liability, not a safety net. The best type is the simplest one that makes the mistake impossible.
Interview answer, one sentence
"The value is turning review comments into compile errors: discriminated
unions with never for props that must not co-occur — including
requiring either children or an aria-label, so an unlabelled icon button
cannot compile — satisfies for lookup tables that must cover
every case, branded ids, and deriving types from the schema or function
rather than restating them."
Opens in the editor: write it, run it, and check it against real tests.
All 54 for this level →Finished reading?
0 of 57 chapters read in this series