learn.colinkim.dev

Exhaustiveness checking

Learn how to use the type system to ensure every case in a union is handled.

Exhaustiveness checking is how TypeScript ensures that you handle every possible case in a union. It works through the never type.

The never assertion pattern

When you handle every variant of a discriminated union, TypeScript knows the remaining type is never — a type that should never have a value:

type Shape = 
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rectangle"; width: 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 "rectangle":
      return shape.width * shape.height;
    default: {
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
    }
  }
}

If you forget a case, TypeScript produces an error:

// Remove the "rectangle" case:
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    // missing "rectangle"
    default: {
      const _exhaustiveCheck: never = shape;
      // Error: Type '{ kind: "rectangle"; width: number; height: number }' 
      // is not assignable to type 'never'.
      return _exhaustiveCheck;
    }
  }
}

The error tells you exactly which case is missing.

A reusable assertion function

Instead of repeating the pattern, extract it:

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

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    case "rectangle":
      return shape.width * shape.height;
    default:
      return assertNever(shape);
  }
}

If a case is missing, assertNever receives a real value where never is expected, and the compiler produces an error. At runtime, the function throws if execution ever reaches it.

Why exhaustiveness matters

Exhaustiveness checking turns the type system into a safety net for business logic. When you add a new variant to a union, every switch statement that is missing the new case breaks at compile time — not at runtime.

This is particularly valuable for:

  • state machines and UI state handling
  • API response variants
  • feature flag combinations
  • error type hierarchies

What to carry forward

  • assign the remaining value to a never-typed variable to check exhaustiveness
  • extract an assertNever function for reuse
  • when a new union variant is added, incomplete switches break at compile time
  • exhaustiveness checking turns the type system into a safety net for logic

The next module covers functions in more depth — callback types, higher-order functions, and contextual typing.

Progress

Quick checks

No quick checks in this lesson.

Mark lesson manually or answer quick checks to track progress.