RizTech Academy logo
RizTech Academy
Unions and NarrowingLesson 5 of 525 min

Exhaustiveness checking with never

Discriminated unions let you handle "one of several kinds". This final lesson of the module makes that handling airtight: exhaustiveness checking lets the compiler prove you handled every case — and, crucially, force you to update every place that switches on the union when you add a new case. It uses the never type from the basic-types module, and it is one of TypeScript's best safety features.

The problem: a forgotten case

Here is a switch over a discriminated union that looks complete:

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

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

Today this handles both cases. But suppose six months later a colleague adds a third shape:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };   // NEW

The area function still compiles — but it now silently returns undefined for a triangle (no case matched, and the function falls off the end). No error, no warning; a real bug waiting for a triangle to appear at runtime. And this switch is probably one of many places in the codebase that handle Shape — every one of them now has the same silent hole, and nothing points you to them. That is the problem exhaustiveness checking solves.

The never trick

The technique: in the default branch, assign the value to a variable of type never. Because a never can only be assigned a value of type never, this compiles only if every case has been handled (so the remaining value is never):

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":    return Math.PI * shape.radius ** 2;
    case "rectangle": return shape.width * shape.height;
    case "triangle":  return (shape.base * shape.height) / 2;
    default:
      const exhaustive: never = shape;    // compiles ONLY if shape is `never` here
      throw new Error(`Unhandled shape: ${JSON.stringify(exhaustive)}`);
  }
}

Here is the reasoning, which is worth following slowly. Inside default, TypeScript has narrowed shape by elimination — it removed "circle", "rectangle", and "triangle" in the earlier cases, so if all three are handled, the only thing shape could be in default is never (nothing left). Assigning a never to const exhaustive: never is fine. But if you forget a case, shape in default is not never — it is the un-handled member — and assigning it to a never variable fails:

// with "triangle" added to Shape but NOT handled in the switch:
default:
  const exhaustive: never = shape;
  // error: Type '{ kind: "triangle"; ... }' is not assignable to type 'never'

The compiler now points at this exact line and says the triangle case is unhandled. Every switch that uses this pattern breaks the moment you add a case and forget to handle it — turning a silent runtime bug into a compile error that walks you to every place you need to update. That is the whole value.

A reusable helper

Rather than write const exhaustive: never = shape everywhere, teams usually define a small helper:

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

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":    return Math.PI * shape.radius ** 2;
    case "rectangle": return shape.width * shape.height;
    case "triangle":  return (shape.base * shape.height) / 2;
    default:
      return assertNever(shape);    // compile error here if a case is missing
  }
}

assertNever(value: never) takes a never, so calling it in default only type-checks if shape has been narrowed to never (all cases handled). If a case is missing, shape is not never and the call errors. It also throws at runtime — a sensible fallback if somehow an unexpected value appears (from untyped data, say). This assertNever helper is a common piece of every serious TypeScript codebase, and it is worth adding to yours.

Why this is a killer feature

Exhaustiveness checking flips the economics of adding a case. Without it, adding a variant to a union is dangerous — you must manually find and update every place that handles it, and a missed one is a silent bug that surfaces later. With it, adding a variant is safe — the compiler lists every place that needs updating, as compile errors, before you can ship. You change the type in one spot, run the type-checker, and it walks you to every switch that now needs a new case. This is the same guarantee that made sealed classes valuable in other languages, and it is one of the strongest arguments for modelling with discriminated unions in the first place — the pattern is not just tidy, it makes your code provably handle every case, now and after every future change.

Check your work

The silent bug exhaustiveness prevents. Adding a case to a union and forgetting to handle it in a switch — which compiles but returns undefined (or worse) at runtime.

The never trick. Assign the value to a never variable in default; it compiles only if every case was handled (so the value narrowed to never).

Why it works. Handling all cases narrows the default value to never by elimination; a forgotten case leaves a non-never value that cannot be assigned to never.

What the compiler does when a case is missing. Errors at the exhaustiveness line, naming the unhandled member — pointing you to every switch to update.

The reusable helper. assertNever(value: never): never — throws at runtime and fails to compile if a case is unhandled.

How it changes adding a case. From dangerous (manually find every handler) to safe (the compiler lists every one as a compile error).

Why it argues for discriminated unions. The pattern makes handling provably complete, now and after every change.

Practice

  1. Write area over a two-case Shape union without exhaustiveness checking. Add a third case to the type but not the switch, and confirm it still compiles (the silent bug).
  2. Add the const exhaustive: never = shape default and confirm the missing case now errors at that line. Read the message.
  3. Handle the third case and confirm the error clears.
  4. Write the assertNever helper and use it in default. Confirm it fails to compile with a missing case and throws at runtime if reached.
  5. Add a fourth case to the union and watch every switch using assertNever break until you handle it. Note how the compiler leads you to each.
  6. Explain, in two sentences, why this makes adding a union case safe rather than dangerous.
  7. Add assertNever to a small project and use it in one discriminated-union switch.

Official documentation

Next module — Generics: reusable code that does not throw its types away.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship