TypeScript15 February 2025 · 9 min read

Advanced TypeScript Patterns for React Developers

Utility types, conditional types, branded types, and the component API patterns that make your codebase impossible to misuse.

The goal of TypeScript is not to type more things. It is to make the wrong code impossible to write. Once you start designing APIs around that, the language stops feeling like ceremony and starts feeling like a tool.

Discriminated unions for component variants

The single most useful pattern. If a component has two modes that take different props, model it as a union, not as a bag of optional fields.

The wrong way:

```ts type ButtonProps = { variant: "link" | "button"; href?: string; onClick?: () => void; }; ```

Nothing stops a user passing `variant: "link"` with an `onClick` and no `href`. The compiler is fine with it. The component breaks at runtime.

The right way:

```ts type ButtonProps = | { variant: "link"; href: string; onClick?: never } | { variant: "button"; onClick: () => void; href?: never }; ```

Now `variant: "link"` requires `href` and forbids `onClick`. The wrong code does not compile. The component never has to defensively check at runtime.

Branded types for values that look the same

An `UserId` and a `PostId` are both strings. The compiler treats them as interchangeable. The bug where you pass the wrong one is silent and painful.

  1. 01

    Brand them `type UserId = string & { readonly __brand: "UserId" }`. The intersection with a phantom field does not affect runtime. It does prevent a raw string from being passed where a `UserId` is expected.

  1. 02

    Create a constructor `const toUserId = (s: string): UserId => s as UserId`. The cast happens once, at the boundary where you trust the value. Inside your code, the type is enforced.

  1. 03

    Use them for anything ambiguous URLs vs paths, encrypted vs plaintext, sanitised vs raw HTML. Any pair of values with the same shape but different meaning is a candidate.

Conditional types for "if A then also B"

When one prop changes the type of another, encode it.

```ts type FormFieldProps<T> = { value: T; onChange: (next: T) => void; } & (T extends string ? { maxLength?: number } : T extends number ? { min?: number; max?: number } : {}); ```

A `FormFieldProps<string>` allows `maxLength`. A `FormFieldProps<number>` allows `min` and `max`. Anything else allows neither. The compiler enforces the relationship instead of the runtime catching it.

Function overloads for polymorphic returns

When a function returns different shapes based on its input, model it explicitly.

```ts function pick<T>(arr: T[], n: 1): T; function pick<T>(arr: T[], n: number): T[]; function pick<T>(arr: T[], n: number): T | T[] { return n === 1 ? arr[0] : arr.slice(0, n); } ```

`pick(items, 1)` returns `T`. `pick(items, 3)` returns `T[]`. No unnecessary narrowing at the call site, no type assertions.

Type-safe context

The default React context API has a problem: it forces you to declare a default value, which usually means `null`, which means every consumer has to null-check.

Wrap the context with an asserting hook

Create the context with `createContext<MyValue | null>(null)`. Then export a custom hook that calls `useContext` and throws if the value is null. Consumers call the hook, never the context directly, and never see the null.

Make the provider type-safe

The provider's props should exactly match the context value's type. If they drift, the compiler tells you, and the consumers update automatically.

Utility types you should actually know

The handful that earn their keep:

  • `Pick<T, K>` and `Omit<T, K>` to derive subsets without duplicating fields.
  • `ReturnType<typeof fn>` to grab a function's return type without exporting it.
  • `Awaited<T>` to unwrap a Promise's resolved type. Essential for typing `async` function results.
  • `NonNullable<T>` after a runtime check to encode "this is no longer undefined."
  • `Parameters<typeof fn>` to type wrappers around existing functions without repeating their signatures.

Everything else in `lib.es5.d.ts` is useful occasionally. These five are useful weekly.

What I avoid

A few patterns that look clever and create more pain than they prevent.

  1. 01

    Deeply nested conditional types If a type is more than two conditions deep, it is a maintenance liability. The error messages become unreadable and editor performance starts to suffer. Split into named helpers.

  1. 02

    `any` as an escape hatch The only justifiable `any` is at an external boundary you do not control. Inside your own code, `unknown` plus a type guard is almost always the right move.

  1. 03

    Library-grade types in product code The compiler tricks that ship in `zod` or `ts-toolbelt` are amazing inside libraries with stable surfaces. In a product codebase that changes daily, they slow everyone down. Use them via libraries; do not write them yourself.

The best types are the ones you stop noticing. They catch the bug, you fix the bug, and the rest of the day the compiler is silent.
Open for one project

Build the AI layer you'd be proud to ship.

If your roadmap has voice, copilots, RAG, or agentic flows on it, the booking link below is the right move. 30 minutes, no pitch, straight answer on whether I can help.

Book a 30-min call
1 slot · June 2026Usually replies within 24 hoursAsync-friendly · UTC+5:30