Exhaustiveness checking with never
Turns a missed union member into a compile error by assigning the leftover value to never.
default: { const _exhaustiveCheck: never = shape; return _exhaustiveCheck; }- Added in TypeScript 2.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$_exhaustiveCheck | never | A 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. |
$shape | never | The 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
returnin every branch. If the switch is a statement with no return, narrowing toneverstill happens but nothing forces you to look. - A discriminant typed
stringinstead of a literal never narrows tonever, so the check silently passes forever. - Linters flag
_exhaustiveCheckas unused. A sharedassertNever(x: never): neverhelper avoids both that and the copy-paste.
Example
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.
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.
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