Skip to content
TypeScript ts narrowing 3 min read

Exhaustiveness Checking

When you handle a discriminated union with a switch, it is easy to add a new variant later and forget to handle it somewhere. Exhaustiveness checking makes that mistake a compile error: by assigning the value to a never-typed variable in the default case, you ask the compiler to confirm that every member has already been handled. If a future variant slips through, never is no longer assignable and the build fails — turning a silent runtime gap into an immediate, located error. This page covers the never default pattern, the assertNever helper, and pairing it with switch and if chains.

Why never enables the check

After narrowing every known case of a union, the remaining type in the default branch is never — the empty type. Assigning a never value succeeds only when nothing is left to handle.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.size ** 2;
    default:
      const _exhaustive: never = shape; // ✅ shape is never here
      return _exhaustive;
  }
}

Because both kinds are handled, shape is narrowed to never in default, so the assignment compiles. The pattern documents intent and locks the union.

Catching a missing case

Add a new variant to the union without updating the switch, and the never assignment immediately fails — pointing you straight at the gap.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number }
  | { kind: "triangle"; base: number; height: number }; // new

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.size ** 2;
    // forgot "triangle"
    default:
      // ❌ Error: Type '{ kind: "triangle"; ... }' is not
      //    assignable to type 'never'.
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

The error names the unhandled variant, so the fix is obvious. Without this guard, area would silently fall through and return undefined for triangles at runtime.

A reusable assertNever helper

Rather than repeat the inline assignment, extract a helper that accepts never and throws. It both satisfies the compile-time check and provides a runtime safety net.

function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}

function describe(shape: Shape): string {
  switch (shape.kind) {
    case "circle":
      return "round";
    case "square":
      return "boxy";
    case "triangle":
      return "pointy";
    default:
      return assertNever(shape); // compile + runtime guarantee
  }
}

The : never return type lets assertNever be used as the default’s return value, keeping the function’s own return type clean.

Exhaustiveness in if/else chains

The same technique works with if/else if ladders — assign to never (or call assertNever) in the final else.

function radius(shape: Shape): number {
  if (shape.kind === "circle") return shape.radius;
  else if (shape.kind === "square") return shape.size / 2;
  else if (shape.kind === "triangle") return 0;
  else return assertNever(shape);
}

Exhaustiveness checking pays off most when paired with discriminated unions. Give every variant a common literal kind field so the compiler can narrow cleanly in each branch.

When to use it

SituationRecommendation
switch over a discriminated unionAdd a never default / assertNever
Union likely to grow over timeStrongly recommended — catches new variants
Reducer / state machine actionsUse assertNever on the action type
Simple boolean or two-state logicOften unnecessary

For reducers, action handlers, and any union you expect to extend, the small amount of boilerplate buys guaranteed coverage.

Best Practices

  • Add a default case that assigns the value to a never variable, or call assertNever.
  • Prefer a shared assertNever(value: never): never helper for runtime and compile-time safety.
  • Pair exhaustiveness checks with discriminated unions that share a literal discriminant.
  • Let inference narrow each case so the default truly receives never.
  • Apply the same pattern to if/else if chains via the final else.
  • Use it on unions you expect to grow — reducers, parsers, and state machines benefit most.
Last updated June 29, 2026
Was this helpful?