Skip to the chapter
←ReactGGroundwork⚛React
React/Advanced/A15
48 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛A15 · Part 3 · Advanced

Advanced TypeScript with React

Making the wrong call fail to compile, rather than fail in review.

5 min read15 sections2 exercises
48 of 57

In this chapter

  • 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

and 10 more

about 5 min
15On this page
  1. Polymorphic components
  2. Mutually exclusive props
  3. Requiring a label, one way or the other
  4. Typing a generic list component
  5. Constraining the generic instead of asking for a function
  6. Compound components with one import
  7. Stopping one prop from deciding the type
  8. Deriving types instead of restating them
  9. Lookup tables that cannot miss a case
  10. Exhaustiveness
  11. IDs that cannot be swapped
  12. Typing context so consumers get a non-null value
  13. Typing custom hooks
  14. Typing an action's result
  15. 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, not any. any disables 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-error becomes 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."

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 6 testsA reusable list that does not know what it rendersadvanced
  • 8 testsassertNever, at runtimeadvanced
All 54 for this level →
A15

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. A14Previous · A14Design systems
  2. A15you are here
  3. Next · A16Architecture at scaleOrganising by feature, drawing boundaries, and the decisions that are hard to reverse.4 min read

01On this page

15 sections · 0%

  1. 01Polymorphic components
  2. 02Mutually exclusive props
  3. 03Requiring a label, one way or the other
  4. 04Typing a generic list component
  5. 05Constraining the generic instead of asking for a function
  6. 06Compound components with one import
  7. 07Stopping one prop from deciding the type
  8. 08Deriving types instead of restating them
  9. 09Lookup tables that cannot miss a case
  10. 10Exhaustiveness
  11. 11IDs that cannot be swapped
  12. 12Typing context so consumers get a non-null value
  13. 13Typing custom hooks
  14. 14Typing an action's result
  15. 15Discipline that matters more than any trick

[] chapters · t top