TypeScript Discriminated Unions: Exhaustive Pattern Matching

Testing my understanding of discriminated unions, type narrowing, and the never type for exhaustive matching in TypeScript.

Discriminated unions are TypeScript’s answer to sum types. I use them daily, but I wanted to verify my understanding across edge cases — especially exhaustive narrowing with never.

The Basic Pattern

type ApiState =
  | { status: 'idle' }
  | { status: 'loading'; progress: number }
  | { status: 'success'; data: string[] }
  | { status: 'error'; message: string };

function handleState(state: ApiState): string {
  switch (state.status) {
    case 'idle':
      return 'Waiting to start';
    case 'loading':
      return `Loading... ${state.progress}%`;
    case 'success':
      return `Got ${state.data.length} items`;
    case 'error':
      return `Error: ${state.message}`;
  }
}

This compiles because every variant is handled. But what happens when someone adds a new variant?

The never Exhaustiveness Check

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'triangle'; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.side ** 2;
    case 'triangle':
      return (shape.base * shape.height) / 2;
    default:
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

The default case assigns shape (which should be never when all cases are handled) to a never variable. If a new variant is added without updating the switch, TypeScript errors at _exhaustive, telling you exactly which line to fix.

Edge Case: Union of Primitives

type Result = string | number | boolean;

function formatResult(r: Result): string {
  if (typeof r === 'string') return `"${r}"`;
  if (typeof r === 'number') return r.toFixed(2);
  // typeof r === 'boolean'
  return r ? 'true' : 'false';
}

TypeScript correctly narrows through the typeof checks. The final return is inferred as boolean. But there’s a subtle issue: if Result is extended to include bigint, the final return silently accepts boolean | bigint — no error. The never default pattern works here too:

function formatResultSafe(r: Result): string {
  if (typeof r === 'string') return `"${r}"`;
  if (typeof r === 'number') return r.toFixed(2);
  if (typeof r === 'boolean') return r ? 'true' : 'false';
  const _exhaustive: never = r;
  return _exhaustive;
}

Edge Case: Discriminated Union With Optional Fields

type Event =
  | { type: 'click'; x: number; y?: number }
  | { type: 'hover'; element: string };

function handleEvent(e: Event) {
  if (e.type === 'click') {
    // e.y is number | undefined
    const yVal = e.y ?? 0;
    console.log(`Click at (${e.x}, ${yVal})`);
  }
}

The y?: number becomes number | undefined inside the narrowed type. Using ?? is correct, but || would be wrong (falsy 0 would get replaced).

What I Learned

  1. never exhaustiveness check works in switch and if/else chains — but not in ternary chains (type narrowing doesn’t propagate across ternary branches the same way).
  2. satisfies operator (TS 4.9+) can validate that a value matches a type without changing its inferred type — useful for ensuring a map covers all variants: const handlers = { ... } satisfies Record<Shape['kind'], Handler>.
  3. Discriminated unions with generics get complex fast. Generic discriminated unions require the discriminator to be a literal type, which means you can’t derive it from a generic parameter without type mapping.

Score: 8/10 — strong on basic patterns, need to practice generic discriminated unions more.