Exhaustiveness checking with never

Turns a missed union member into a compile error by assigning the leftover value to never.

Signature
default: { const _exhaustiveCheck: never = shape; return _exhaustiveCheck; }
  • Added in TypeScript 2.0

Parameters

ParameterTypeDescription
$_exhaustiveCheckneverA local annotated as never. Only a value of type never is assignable to it, so it compiles only when every member has been eliminated by the preceding branches.
$shapeneverThe narrowed union value in the default branch. If a member is unhandled it still has that member's type here, and the assignment errors with "Type 'X' is not assignable to type 'never'".

Return value

neverNothing. The construct exists for its compile-time effect: if any union member is unhandled, the assignment to never fails to type-check.

Errors

Nothing at compile time. At runtime the branch is normally written to throw, which fires only if a value from outside the declared type reaches it.

Changelog

  • PHP 2.0The never type introduced; no type is assignable to never except never itself, which is what makes the check work.
  • PHP 3.7Assertion signatures allow the same idea as a reusable helper: `function assertNever(x: never): never { throw new Error(...) }`.

This is the pattern that makes a union safe to extend. After a switch handles every member, the value in the default branch has been narrowed to never — nothing is left. Assign it to a variable annotated never and the assignment succeeds. Add a member to the union without adding a case and the value in default is now that member, which is not assignable to never, so the file stops compiling.

Why it is worth the three lines

Without it, adding { kind: "triangle" } to a union changes nothing visible: every switch that missed it just falls through and returns undefined. The bug surfaces in production. With it, the compiler names every file you have to update.

What trips people up

  • The function needs an explicit return type, or a return in every branch. If the switch is a statement with no return, narrowing to never still happens but nothing forces you to look.
  • A discriminant typed string instead of a literal never narrows to never, so the check silently passes forever.
  • Linters flag _exhaustiveCheck as unused. A shared assertNever(x: never): never helper avoids both that and the copy-paste.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Guaranteeing every reducer action is handled when a teammate adds a new one
  • Forcing an update everywhere a status enum gains a value
  • Failing the build when a new AST node type is introduced

Frequently asked questions

Why does my exhaustiveness check compile even though I missed a case?

Usually the discriminant is typed `string` rather than a literal union, so the default branch never narrows to never. Check that each member declares something like `kind: "circle"`, not `kind: string`.

Is there a way to do this without an unused variable?

Yes — write `function assertNever(x: never): never { throw new Error(...) }` and call `return assertNever(value)` in the default branch. It returns never, so it satisfies any return type, and nothing is left unused.

Does the default branch ever run?

Only when a value from outside the declared type reaches it — untyped JSON, an `any` cast, or an older client sending a new tag. Keeping a throw there converts that into a loud failure instead of a silent undefined.

More examples

What the compiler says when a member is missed

Prints 12.57 when run, because the circle case is handled — type stripping does not type-check. Under tsc this file does not compile: the default branch has narrowed Shape down to the triangle member, and no type other than never is assignable to never. That error is the whole point of the pattern.

Example · typescript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; sideLength: number }
  | { kind: "triangle"; base: number; height: number }; // newly added

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "square": return shape.sideLength ** 2;
    // triangle deliberately not handled
    default: {
      const _exhaustiveCheck: never = shape;
      //    ~~~~~~~~~~~~~~~~
      // Type '{ kind: "triangle"; base: number; height: number; }'
      //   is not assignable to type 'never'.
      return _exhaustiveCheck;
    }
  }
}

console.log(area({ kind: "circle", radius: 2 }).toFixed(2));

A reusable assertNever helper

Prints increment -> 5, increment -> 7, reset -> 0. The helper returns `never`, so the default branch also satisfies the function's return type without a redundant throw, and no linter complains about an unused variable. Adding a third action type breaks the build at the assertNever call.

Example · typescript
function assertNever(value: never, context: string): never {
  throw new Error(`${context}: unexpected ${JSON.stringify(value)}`);
}

type Action =
  | { type: "increment"; by: number }
  | { type: "reset" };

function reduce(count: number, action: Action): number {
  switch (action.type) {
    case "increment": return count + action.by;
    case "reset": return 0;
    default: return assertNever(action, "reduce");
  }
}

let state = 0;
for (const a of [{ type: "increment", by: 5 }, { type: "increment", by: 2 }, { type: "reset" }] as Action[]) {
  state = reduce(state, a);
  console.log(a.type, "->", state);
}

Discussion

  • Be the first to comment on this lesson.