Topic 4.6
Exhaustiveness Checking with never
In one line
never is the type with no values. If you handle every member of a union and then try to assign the leftover to never, it works; if you forgot a member, the compiler reports exactly which one. Adding a new variant then points you to every place that needs updating.
Think of it like this
A checklist at the end of a flight. Before the plane leaves, the captain ticks off every item. If one is missing, the plane can't go. **Exhaustiveness checking is that final checklist for your switch:** the compiler holds a list of every variant of your union, and refuses to compile until each one has been handled.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- never
- The empty type: no value has it. It marks code that cannot be reached.
- Exhaustive
- Covering every possible case. An exhaustive
switchhandles every member of the union. - Leftover type
- What remains of a union after the checks have removed the handled members.
- assertNever
- A helper taking a
neverparameter and throwing. Passing it a non-nevervalue is a compile error. - default branch
- The
default:part of aswitch, run when nocasematched. - Unreachable code
- Code that can never run, because the checks before it rule out every case.
- Safety net
- A runtime check (here the
throw) that catches what the types could not know.
Step by step
01Handle every case, look at the leftover
Shape has two variants. A switch handles both. In the default, the compiler has nothing left: shape is never.
Hover the variable in the first example below to see it. A never can be assigned to every type, so returning it is also allowed.
02Forget a case, and the leftover is the missing one
Now add a third variant, triangle, and don't write a case for it. The default branch receives the triangle, so const unreachable: never = shape is an error.
The error text is the full type of the missing variant. That's your to-do list: write case "triangle".
default: {
const unreachable: never = shape;
// error TS2322: Type '{ kind: "triangle"; base: number; height: number; }' is not assignable to type 'never'.
return unreachable;
}03Wrap it in assertNever
Instead of repeating the assignment, put it in a function. The compile-time part is the parameter type never. The runtime part is the throw.
In the default branch write return assertNever(shape). When a case is missing, the error is on that line: Argument of type '{ kind: "triangle"; ... }' is not assignable to parameter of type 'never'.
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}04Other ways to get exhaustiveness
A function whose return type is not undefined and whose switch has no match for some case fails with TS2366: Function lacks ending return statement. A Record<Light, number> must list every light. Choose the style your team can read; assertNever is the clearest and also protects at runtime.
05When data lies
If the server sends a kind that isn't in your type, the types are wrong about reality. assertNever throws with the offending value, so the failure is loud and the message tells you what arrived.
Try it yourself
- 1
Fix the triangle
In the second example add
case "triangle": return (shape.base * shape.height) / 2;beforedefault. The error vanishes and the last line prints6.Now remove the
squarecase instead. Which type does the error show? - 2
Add a variant to the first example
In the first example add
| { kind: "triangle"; base: number; height: number }toShape. Read the error message completely: it contains everything you need to write the new case. - 3
Return types matter
In the third example, add
: Lighttonext2. The missing-return error appears there too. Then add theyellowcase to both functions and the errors vanish.
Code & diagrams
Every kind has a case, so the default branch can never run and shape is never there. Try adding a third member to Shape and read the new error.
Console
(nothing was printed)Expected console output
9The triangle has no case, so the error is reported on the assertNever(shape) line and lists the whole missing type. The code still runs: the square works and the triangle reaches the throw. Add case "triangle": return (shape.base * shape.height) / 2; to fix it.
Console
(nothing was printed)Expected console output
16
Uncaught Error: Unexpected value: {"kind":"triangle","base":3,"height":4}With a return type written, the missing yellow case is reported at the function. Without one, TypeScript quietly adds undefined to the result (see r), which is why writing return types on functions that switch is a good habit. Record<Light, number> demands all three keys.
Console
(nothing was printed)Expected console output
undefinedBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Add a variant without a case
In the first example, add | { kind: "triangle"; base: number; height: number } as a third member of Shape.
Myth vs fact
Myth
never is the same as void.
Fact
void means "returns nothing useful" (and the function does return). never means the function never returns, or a value can't exist.
Myth
A default branch makes a switch safe.
Fact
A plain default silently swallows new variants. A default that assigns to never or calls assertNever fails the build instead.
Myth
Exhaustiveness checking guards against bad runtime data.
Fact
Only against types. Use the runtime throw in assertNever and validate external data where it enters.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
neveris the bottom type: it is assignable to every type, and nothing (exceptnever) is assignable to it. That is whyconst x: never = shapeonly compiles when the leftover is empty, and why a function returningnevercan appear where any type is expected. In unions it disappears (T | neverisT), which conditional types use to filter members (later in the course). - ▸
A
switchwhose cases cover everything lets the compiler treat the end of the function as unreachable. This is whyareaabove needs no finalreturneven with: number. The same analysis is behindnoImplicitReturnsandnoFallthroughCasesInSwitch; both are worth enabling.strictdoes not include them. - ▸
The linter rule
@typescript-eslint/switch-exhaustiveness-checkreports missing cases without any code indefault. By default it still reports missing members when adefaultexists; the optionconsiderDefaultExhaustiveForUnions: truemakes it accept adefaultas covering the rest. Choose between the lint rule andassertNeveras a team convention; many teams use both.
Remember this
- 1
The problem this solves: When someone adds a new variant to a union, every
switchthat handles it must be updated, and a forgotten one silently does nothing. The compiler can point at each one. **never** is TypeScript's empty type: a type that has no possible value at all. A variable of typenevercan never exist, because there is nothing to put in it. A function that always throws, or loops forever, has return typenever. It sounds useless, but it's the key to this topic: it represents "this place in the code must be unreachable". - 2
Remember narrowing by elimination (Topics 4.3 to 4.5). Each time you check a union and handle a member, the compiler removes that member from what's left. If you handle every member, nothing is left, and the leftover type is **
never**. In thedefaultbranch of aswitchover a discriminated union with all cases covered,shapehas typenever. - 3
That gives a trick. In the
defaultbranch writeconst unreachable: never = shape;. If every case is handled,shapeisneverand the assignment is fine. If you forgot one,shapeis the missing member, and assigning it toneveris an error that prints the missing member's full type:Type '{ kind: "triangle"; base: number; height: number; }' is not assignable to type 'never'. You read the message and know exactly which case to write. - 4
Wrapping the trick in a function makes it reusable:
function assertNever(value: never): never { throw new Error(...) }, and thedefaultbranch becomesreturn assertNever(shape). The parameter typeneverdoes the compile-time check. Thethrowis a runtime safety net: if data from outside the type system (an API, a stale cache, a newer server) sends a variant your code doesn't know, the program fails loudly with a clear message instead of silently continuing with the wrong behaviour. - 5
This is how adding a new variant becomes safe. You add
"triangle"toShape. Everyswitchthat ends withassertNever(shape)now fails to compile, each with an error at thatdefaultline. You work through the list, and when the compiler is silent, nothing was forgotten. Without the check, the new variant would fall into adefaultor produceundefinedin some forgotten corner, and you'd learn about it in production. - 6
Other ways to get the same checking. A function with a declared return type and a
switchwith nodefaultand no matching case givesTS2366: Function lacks ending return statement and return type does not include 'undefined', so you often get exhaustiveness for free. ARecord<Light, number>object must have a key for every light, orTS2741names the missing one. And the linter rule@typescript-eslint/switch-exhaustiveness-checkreports missing cases, even in functions without a return type. - 7
A limit worth saying plainly. Exhaustiveness is checked against the types, not against runtime data. If you wrote the type as
"circle" | "square"but the JSON from a server contains"hexagon", TypeScript can't see it. That's whyassertNeveralso throws at runtime, and why data from outside should be validated where it enters (Topic 4.7 and the validation topics later).
Explain it without notes
What is the never type and why is it useful for switch statements?
How does assertNever give both compile-time and runtime safety?
What happens to your code when you add a new member to a union that is switched over with assertNever?
What can exhaustiveness checking not catch?
Practice
Given type Level = "debug" | "info" | "warn" | "error";, write function icon(l: Level): string as a switch that ends with assertNever. Then remove one case and write down the error.
Write assertNever yourself, then add a fifth level "fatal" to Level and list what tsc reports for icon.
Replace the switch in icon with an object const ICONS: Record<Level, string> = .... What does adding "fatal" to Level now do?
Trade-offs
- ↔
assertNeveradds one helper and onedefaultper switch, and gives you refactor safety plus a runtime guard. The cost is that every new variant forces edits in all those switches, which is exactly the purpose. - ↔
Not every
switchshould be exhaustive. A function that only cares about two of ten variants should say so with a normaldefault. UseassertNeverwhere silent fall-through would be a bug.
Done when you can
I can explain
neveras the empty type.I can write the
const x: never = valuetrick and read its error.I can write and use
assertNever.I can name two other ways to force all cases (return type and
Record).I can say what exhaustiveness checking does not protect against.