TypeScript Discriminated Unions: Exhaustive Pattern Matching

A practical guide to TypeScript discriminated unions with exhaustive pattern matching, the never type, and real-world detection patterns for your codebase.

Discriminated unions are TypeScript’s answer to sum types — they let you model data that can be one of several shapes, with the compiler narrowing types automatically. This guide covers the basic pattern, the never exhaustiveness check, edge cases with optional fields and primitive unions, and how to enforce exhaustive matching across your codebase.

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 This Means for Practitioners

  1. Adopt the never exhaustiveness check as a team standard — every switch on a discriminated union should have a default: const _exhaustive: never = val; branch. Enforce this with a lint rule (TypeScript-ESLint’s switch-exhaustiveness-check). Without it, adding a new variant silently returns undefined at runtime — the compiler won’t catch it.

  2. Use satisfies (TS 4.9+) to validate handler maps — instead of switch statements, you can define handler objects: const handlers = { ... } satisfies Record<Shape['kind'], Handler>. TypeScript errors if any variant is missing a handler. This pattern is cleaner for medium-to-large unions (5+ variants) and catches missing handlers at compile time.

  3. Add tsc --noEmit to CI if you haven’t already — the never exhaustiveness check produces a Type '...' is not assignable to type 'never' error that tsc surfaces. But this only helps if type-checking runs in CI. Without --noEmit in your build pipeline, these errors are invisible until a developer runs tsc locally.

  4. Watch for generic discriminated unions — the discriminator must be a literal type. If you derive it from a generic parameter, the compiler can’t narrow correctly. Use a helper type that maps each variant to its discriminator, or avoid generics on the discriminant field.

  5. Check optional fields inside narrowed branchesy?: number becomes number | undefined inside the narrowed type. Use ?? (not ||) for defaults to avoid replacing valid falsy values like 0.

How to Detect This in Your Code

grep for Missing never Checks

# Find switch statements on discriminated unions missing the default/never pattern
grep -rn "switch.*\.\(kind\|type\|status\)" src/**/*.ts
# Then manually check: does the switch have a `default: const _exhaustive: never = ...`?

# Find handler maps (objects) that might miss variants
grep -rn "satisfies Record" src/**/*.ts
# These are already safe — add them where they're missing

# Find any if/else chains that narrow on a union discriminator without a never check
grep -rn "typeof.*===.*'string'" src/**/*.ts | head -10

Detection Checklist

PatternRiskFix
switch on enum/unionMissing variant = silent undefined returnAdd default: const _exhaustive: never = val;
if/else chain on typeofNew type added = last branch silently widensAdd final else with _exhaustive: never check
Handler object (no satisfies)New variant = handler returns undefinedAdd satisfies Record<UnionType['kind'], Handler>
No --noEmit in CIUnused variables, broken types ship to prodAdd tsc --noEmit to CI pipeline

Quick Fix: Migrate a Switch Without never

Before:

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle': return Math.PI * shape.radius ** 2;
    case 'square': return shape.side ** 2;
    // If 'triangle' is added, no compile error — returns undefined
  }
}

After:

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle': return Math.PI * shape.radius ** 2;
    case 'square': return shape.side ** 2;
    default:
      const _exhaustive: never = shape;  // 🔴 TypeScript error if variant missing
      return _exhaustive;
  }
}

Common Codebase Patterns to Watch

  • Redux reducers: Most Redux reducers use switch (action.type) — these are discriminated unions. Add a default: const _exhaustive: never = action; to catch new action types at compile time.
  • React component variants: switch (props.variant) or props.status — a new variant silently falls through to the default render path.
  • API response handlers: switch (response.status) — adding a new status code doesn’t error unless there’s a never check.

See TypeScript Handbook: Discriminated Unions, TypeScript Handbook: The never type, and TypeScript 4.9: satisfies operator for official references.

Quick Start Checklist

Adding discriminated unions with exhaustive matching to a new or existing TypeScript project:

  1. Define the union type — start with a literal kind, type, or status discriminator field. Each variant must have a unique literal value for the discriminator. Example: type Shape = | { kind: 'circle'; radius: number } | { kind: 'square'; side: number }

  2. Write the handler function — use a switch on the discriminator field. TypeScript narrows the type inside each case block automatically. No manual type assertions needed.

  3. Add the never exhaustiveness check — in the default branch, assign the narrowed variable to a never-typed variable: const _exhaustive: never = shape;. This catches new variants at compile time.

  4. Validate handler maps with satisfies — if you prefer a handler-map pattern (object mapping discriminators to functions), wrap it: const handlers = { ... } satisfies Record<Shape['kind'], Handler>. TypeScript errors if any variant is missing a handler.

  5. Watch optional fields — inside narrowed branches, optional fields become T | undefined. Use ?? (not ||) for defaults to avoid replacing valid falsy values like 0.

  6. Run tsc --noEmit — a new variant without a handler produces a Type '...' is not assignable to type 'never' error pointing to the exact _exhaustive line. Fix by adding a new case block.

Key Takeaways

  • Always add a never exhaustiveness check in the default branchconst _exhaustive: never = shape; turns a missed variant into a compile-time error. Without it, adding a new variant silently produces undefined behavior at runtime. This is the single highest-ROI pattern in this post.
  • Use never in both switch and if/else chains — the pattern works with both control flow structures. The last else branch assigns the narrowed type to a never variable. It does NOT work in ternary chains (type narrowing doesn’t propagate across ternary branches the same way).
  • Use the satisfies operator (TS 4.9+) to validate handler maps — instead of manually checking that every variant has a handler, define your handlers as a record keyed by the discriminator: const handlers = { ... } satisfies Record<Shape['kind'], Handler>. TypeScript errors if any variant is missing.
  • Watch for optional fields in discriminated unionsy?: number becomes number | undefined inside the narrowed type. Use ?? (not ||) to provide defaults, since || would replace valid falsy values like 0.
  • Generic discriminated unions need careful design — the discriminator must be a literal type, which means you can’t derive it from a generic parameter without type mapping. If you need generic discriminated unions, consider using a helper type that maps each variant to its discriminator.

|`