Exhaustiveness with never
Add a new case anywhere and the compiler tells you which switches you forgot to update.
Six months after launch, the team adds a new payment method — Apple Pay. Someone updates the PaymentMethod union to include it. The build is green. The app ships. A week later, someone notices that on the receipt screen, Apple Pay payments show as "Unknown method." That receipt screen had a switch statement over the union, and nobody remembered to add a case for the new arm. This lesson is the small piece of code that would have caught it at build time.
The forgetful switch
A discriminated union is everywhere now. Most code that consumes one ends up in a switch over the discriminant.
type PaymentMethod =
| { kind: "card"; last4: string }
| { kind: "paypal"; email: string };
function describe(p: PaymentMethod): string {
switch (p.kind) {
case "card": return "Card ending " + p.last4;
case "paypal": return "PayPal " + p.email;
}
return "Unknown method"; /* the bug-friendly fallback */
}This works today. Both arms are handled. The trailing return is just there to satisfy the compiler that describe always returns a string. Six months from now, somebody adds { kind: "applepay"; deviceName: string } to PaymentMethod. They go grep for files that switch on kind and update the obvious ones. They miss this file. The switch falls through to "Unknown method" and the bug ships.
The compiler had no way to flag the omission. As far as it knew, the function still returned a string in every case. The fallback was the problem — it absorbed the missing arm.
What never means
The never type is TypeScript's name for "no value can be of this type." It's the bottom of the type system. A function that throws unconditionally returns never (because it never produces a value to return). A variable narrowed to no remaining arms of a union has type never.
That second case is the trick this lesson hangs on. Inside a switch over a discriminated union, after every arm has been handled, the discriminant has been narrowed to the empty set — never. If you reach that point with a value still typed as something else, the compiler knows you missed a case.
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; side: number };
function area(s: Shape): number {
if (s.kind === "circle") return Math.PI * s.r * s.r;
if (s.kind === "square") return s.side * s.side;
/* here, s has type never — every arm was handled */
}After the two if checks, s could not possibly be a circle or a square, and the type Shape has no other arms. So s is narrowed to never. If you tried to use s in that final position, the compiler would let you — never is assignable to anything — but if you assigned s to a variable typed as something specific, you could trigger a check.
assertNever
The pattern is a tiny helper function that takes a never and throws.
function assertNever(value: never): never {
throw new Error("unreachable: " + JSON.stringify(value));
}The function takes a parameter typed never. That's an unusual signature — it means "you can only call this with a value the type system has already proven can't exist." Reaching the function at runtime means a bug, so it throws. The body throws unconditionally, which is why its return type is also never.
Now drop it into the switch's default branch.
function describe(p: PaymentMethod): string {
switch (p.kind) {
case "card": return "Card ending " + p.last4;
case "paypal": return "PayPal " + p.email;
default: return assertNever(p);
}
}In today's code, every arm of PaymentMethod is handled. By the time control reaches default, p has been narrowed to never, and assertNever(p) compiles cleanly.
Now imagine adding { kind: "applepay"; ... } to the union. The two case arms still cover card and paypal. When control falls through to default, p is no longer never — it's { kind: "applepay"; ... }. Passing that value to a function expecting never is a compile error. The build breaks. The compiler points right at this file.
Think of assertNever as a tripwire wired up at compile time. It costs almost nothing — five lines, no runtime work in the happy path — and turns "did I update every switch?" from a manual chore into a build error.
assertNever(p) in the default branch when a new arm is added but the case statements aren't updated?Where it pays off
The pattern earns its keep anywhere a union is consumed in more than one place.
A reducer is the textbook case. Each action is a tagged variant of an Action union; the reducer switches on action.type. When a feature adds a new action, the reducer is the first thing that has to change. Without assertNever, you find out at runtime when that action does nothing. With it, the build breaks.
type Action =
| { type: "increment"; by: number }
| { type: "reset" };
function reducer(state: number, action: Action): number {
switch (action.type) {
case "increment": return state + action.by;
case "reset": return 0;
default: return assertNever(action);
}
}The same pattern works for event handlers, command dispatchers, route matchers — anywhere you have a union and you want the next teammate to be told, by the build, that they forgot a case.
It works inside if/else if chains too, not only switch. The trick is the same: handle each arm and then call assertNever on whatever's left.
function describe(p: PaymentMethod): string {
if (p.kind === "card") return "Card " + p.last4;
if (p.kind === "paypal") return "PayPal " + p.email;
return assertNever(p);
}The shape is identical. After both ifs, p is narrowed to never if the union has been fully covered. Add a new arm, and that final line refuses to compile.
assertNever won't help if your switch has a fallthrough default that returns a sensible-looking value. The whole pattern relies on the default being unreachable. A friendly fallback hides exactly the bug you wanted to surface.
return assertNever(action); with return state; in the reducer's default. What do you lose?