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
- Write
areaover a two-caseShapeunion without exhaustiveness checking. Add a third case to the type but not the switch, and confirm it still compiles (the silent bug). - Add the
const exhaustive: never = shapedefault and confirm the missing case now errors at that line. Read the message. - Handle the third case and confirm the error clears.
- Write the
assertNeverhelper and use it indefault. Confirm it fails to compile with a missing case and throws at runtime if reached. - Add a fourth case to the union and watch every switch using
assertNeverbreak until you handle it. Note how the compiler leads you to each. - Explain, in two sentences, why this makes adding a union case safe rather than dangerous.
- Add
assertNeverto a small project and use it in one discriminated-union switch.
Official documentation
- TypeScript — Exhaustiveness checking — The
neverpattern, from the source. - TypeScript — The
nevertype — Why it enables this. - TypeScript — Discriminated Unions — Which this completes.
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