Exhaustiveness Checking
When you handle a discriminated union with a switch, it is easy to add a new variant later and forget to handle it somewhere. Exhaustiveness checking makes that mistake a compile error: by assigning the value to a never-typed variable in the default case, you ask the compiler to confirm that every member has already been handled. If a future variant slips through, never is no longer assignable and the build fails — turning a silent runtime gap into an immediate, located error. This page covers the never default pattern, the assertNever helper, and pairing it with switch and if chains.
Why never enables the check
After narrowing every known case of a union, the remaining type in the default branch is never — the empty type. Assigning a never value succeeds only when nothing is left to handle.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number };
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; // ✅ shape is never here
return _exhaustive;
}
}
Because both kinds are handled, shape is narrowed to never in default, so the assignment compiles. The pattern documents intent and locks the union.
Catching a missing case
Add a new variant to the union without updating the switch, and the never assignment immediately fails — pointing you straight at the gap.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number }
| { kind: "triangle"; base: number; height: number }; // new
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.size ** 2;
// forgot "triangle"
default:
// ❌ Error: Type '{ kind: "triangle"; ... }' is not
// assignable to type 'never'.
const _exhaustive: never = shape;
return _exhaustive;
}
}
The error names the unhandled variant, so the fix is obvious. Without this guard, area would silently fall through and return undefined for triangles at runtime.
A reusable assertNever helper
Rather than repeat the inline assignment, extract a helper that accepts never and throws. It both satisfies the compile-time check and provides a runtime safety net.
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
function describe(shape: Shape): string {
switch (shape.kind) {
case "circle":
return "round";
case "square":
return "boxy";
case "triangle":
return "pointy";
default:
return assertNever(shape); // compile + runtime guarantee
}
}
The : never return type lets assertNever be used as the default’s return value, keeping the function’s own return type clean.
Exhaustiveness in if/else chains
The same technique works with if/else if ladders — assign to never (or call assertNever) in the final else.
function radius(shape: Shape): number {
if (shape.kind === "circle") return shape.radius;
else if (shape.kind === "square") return shape.size / 2;
else if (shape.kind === "triangle") return 0;
else return assertNever(shape);
}
Exhaustiveness checking pays off most when paired with discriminated unions. Give every variant a common literal
kindfield so the compiler can narrow cleanly in each branch.
When to use it
| Situation | Recommendation |
|---|---|
switch over a discriminated union | Add a never default / assertNever |
| Union likely to grow over time | Strongly recommended — catches new variants |
| Reducer / state machine actions | Use assertNever on the action type |
| Simple boolean or two-state logic | Often unnecessary |
For reducers, action handlers, and any union you expect to extend, the small amount of boilerplate buys guaranteed coverage.
Best Practices
- Add a
defaultcase that assigns the value to anevervariable, or callassertNever. - Prefer a shared
assertNever(value: never): neverhelper for runtime and compile-time safety. - Pair exhaustiveness checks with discriminated unions that share a literal discriminant.
- Let inference narrow each case so the
defaulttruly receivesnever. - Apply the same pattern to
if/else ifchains via the finalelse. - Use it on unions you expect to grow — reducers, parsers, and state machines benefit most.