Skip to content

Exhaustiveness Checking

Exhaustiveness checking is TypeScript’s way of verifying that your logic covers all variants of a union type. If you miss a case, TypeScript will warn you at compile time—long before the bug hits production.

This is especially important when modeling:

  • API responses
  • UI states
  • Redux reducers
  • State machines
  • Discriminated unions

Let’s break down how it works.


never and Switch Statements

The never type represents a value that should not exist.

It’s the perfect tool for detecting missing cases.

Example: A discriminated union

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

Switch with exhaustiveness checking

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2

    case "square":
      return shape.size ** 2

    case "rectangle":
      return shape.width * shape.height

    default:
      const _exhaustive: never = shape
      return _exhaustive
  }
}

What’s happening here?

  • If all cases are handled, shape in the default branch is never.
  • If you forget a case, TypeScript will infer that shape is not never, and you’ll get a compile‑time error.

This is TypeScript’s way of saying:

“You forgot to handle one of the variants.”


Catching Missing Cases

Let’s see what happens if you forget "rectangle".

Remove the rectangle case

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 // ❌ Error!
      return _exhaustive
  }
}

TypeScript error:

Type '{ kind: "rectangle"; width: number; height: number; }' is not assignable to type 'never'.

This is exactly what we want—TypeScript is preventing a runtime bug.


Exhaustiveness Checking Without a Switch

You can also enforce exhaustiveness in if/else chains.

Example

function handle(result: Result) {
  if (result.status === "success") {
    return result.data
  }

  if (result.status === "error") {
    return result.error.message
  }

  // Exhaustiveness check
  const _exhaustive: never = result // ❌ Error if "loading" is missing
  return _exhaustive
}

This pattern works anywhere, not just in switch statements.


Why Exhaustiveness Checking Matters

1. Prevents silent failures

If a new variant is added to a union, TypeScript forces you to handle it.

2. Makes refactoring safe

You can confidently evolve your types without breaking logic.

3. Eliminates unreachable code

If a branch is impossible, TypeScript will tell you.

4. Encourages explicit, predictable logic

Your code becomes easier to understand and maintain.


Real‑World Example: UI States

This is where exhaustiveness checking shines.

type UIState =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "success"; data: string[] }

Component logic

function render(state: UIState) {
  switch (state.status) {
    case "loading":
      return "Loading..."

    case "error":
      return `Error: ${state.message}`

    case "success":
      return `Items: ${state.data.join(", ")}`

    default:
      const _exhaustive: never = state // ❌ Error if a case is missing
      return _exhaustive
  }
}

If someone later adds:

{ status: "empty" }

TypeScript will immediately flag every missing case.

This is how large teams avoid subtle UI bugs.


Putting It All Together

Here’s a complete example combining narrowing, discriminated unions, and exhaustiveness checking:

type Response =
  | { type: "ok"; payload: string }
  | { type: "not_found" }
  | { type: "unauthorized"; reason: string }

function handleResponse(res: Response) {
  switch (res.type) {
    case "ok":
      return res.payload

    case "not_found":
      return "Not found"

    case "unauthorized":
      return `Unauthorized: ${res.reason}`

    default:
      const _exhaustive: never = res // ❌ Error if a case is missing
      return _exhaustive
  }
}

This pattern is used in production systems everywhere—from backend services to frontend state machines.


Summary

In this lesson, you learned how to use exhaustiveness checking to make your TypeScript code safer and more robust.

1. never and switch statements

  • never represents impossible states
  • Use it in default to catch missing cases

2. Catching missing cases

  • TypeScript warns you when a union variant isn’t handled
  • Works in both switch statements and if/else chains

3. Real‑world reliability

  • Prevents bugs when types evolve
  • Ensures all states are handled
  • Makes refactoring predictable

Exhaustiveness checking is one of the biggest advantages TypeScript has over plain JavaScript—it turns entire categories of runtime bugs into compile‑time errors.