You add a new payment method to a union type. The checkout page, the receipt email, the fee calculator and the admin dashboard each have a switch over that union. How many of them did you remember to update? An exhaustiveness check turns that question into a list of compile errors, one per forgotten switch. It’s built on the never type, and it’s the most practical thing never does in everyday code.
What never means
never is the type that has no values. Nothing can be assigned to it except another never. You meet it in a few places:
- A function that never returns normally, because it always throws or loops forever, has return type
never.function fail(msg: string): never { throw new Error(msg); }is the classic example. - An impossible type collapses to it.
string & numberisnever, since no value is both, andconst i: string & number = "a"fails withType '"a"' is not assignable to type 'never'. - Narrowing removes possibilities until none are left.
That last one is the key. If x is string | number, and you’ve returned in both the typeof x === "string" branch and the typeof x === "number" branch, then any code after those branches can only run if x is something that doesn’t exist. TypeScript types x as never there. An exhaustiveness check is a deliberate assignment to never at exactly that point.
A union that grows
Here’s a payment method union, already extended with a new giftcard member that nobody has handled yet:
type PaymentMethod =
| { kind: "card"; last4: string }
| { kind: "paypal"; email: string }
| { kind: "bank"; iban: string }
| { kind: "giftcard"; code: string };And a helper that turns the “impossible” case into a type error:
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${JSON.stringify(value)}`);
}
function label(method: PaymentMethod): string {
switch (method.kind) {
case "card":
return `Card ending ${method.last4}`;
case "paypal":
return `PayPal (${method.email})`;
case "bank":
return `Bank transfer`;
default:
return assertNever(method);
}
}nev1.ts(20,26): error TS2345: Argument of type '{ kind: "giftcard"; code: string; }' is not assignable to parameter of type 'never'.Walk through what the compiler does. Each case narrows method and returns, removing that member from the possibilities. By the time control reaches default, three members have been removed. If all four had been handled, method would be never there and passing it to assertNever would be fine. Because giftcard is still possible, method is { kind: "giftcard"; code: string }, and that can’t be passed to a never parameter. The error even names the member you forgot.
Add a case "giftcard": and the error disappears. Do the same in every function that reports one, and you’ve updated the whole codebase without searching for anything.
Three ways to write the check
The assertNever helper is the most common version, but there are two others, and you’ll see all three.
A typed variable needs no helper:
default: {
const unreachable: never = method;
return unreachable;
}nev1.ts(30,13): error TS2322: Type '{ kind: "giftcard"; code: string; }' is not assignable to type 'never'.Since TypeScript 4.9, satisfies reads well for this:
default:
method satisfies never;
return "?";nev1.ts(42,14): error TS1360: Type '{ kind: "giftcard"; code: string; }' does not satisfy the expected type 'never'.They all produce the same compile-time guarantee. The difference is runtime behaviour, which the next section is about. assertNever throws, the typed variable returns whatever you choose, and the satisfies line does nothing at runtime, so execution continues to the fallback.
Mappings are exhaustive too
Not every exhaustive check needs a switch. When each member maps to a fixed value, such as an icon, a label or a fee, a Record keyed by the discriminant does the same job with less code:
const ICONS: Record<PaymentMethod["kind"], string> = {
card: "card.svg",
paypal: "paypal.svg",
bank: "bank.svg",
};nev1.ts(47,7): error TS2741: Property 'giftcard' is missing in type '{ card: string; paypal: string; bank: string; }' but required in type 'Record<"bank" | "card" | "giftcard" | "paypal", string>'.PaymentMethod["kind"] is the union of all kind values, and Record requires a property for each one. A missing key is an error, and so is an extra one. Lookup tables like this are often clearer than a switch for data that doesn’t need per-case logic.
The default that hides everything
This is the mistake interviewers listen for. Here’s a version of label that compiles cleanly, even though giftcard isn’t handled:
switch (method.kind) {
case "card": return "Card";
case "paypal": return "PayPal";
default: return "Other";
}The catch-all default absorbs every unhandled member, including ones added next year, so the compiler has nothing to complain about. Gift card payments will show up as “Other” in production and nobody will notice for a while. If a function is meant to handle every member, the default branch should contain the never check and nothing that hides a missing case.
Similar trouble comes from if chains that don’t return in every branch, or from a function typed as returning string | undefined, where falling off the end is legal. The check only works at a point the compiler agrees is unreachable.
Runtime data doesn’t read your types
The never check is a compile-time guarantee about your own code. It says nothing about data from outside. If the payments API starts returning { kind: "crypto" } before your frontend is updated, the value reaches default at runtime even though the type says it can’t. Here’s what assertNever does with it:
node -e '
function assertNever(value) { throw new Error(`Unhandled value: ${JSON.stringify(value)}`); }
try { assertNever({ kind: "crypto", wallet: "0xabc" }) } catch (e) { console.log(e.message) }'
# Unhandled value: {"kind":"crypto","wallet":"0xabc"}Whether that’s right depends on where the code runs. In a backend handler that must not process unknown payment types, throwing is correct. In a UI list rendering, crashing the page over one unrecognised row is worse than showing a generic label, so you’d use the satisfies never form, log the value and return a fallback. You get the compile-time check either way; you’re only choosing the runtime behaviour. Validating API responses at the boundary, so unknown kinds are rejected before they reach this code, removes most of the dilemma.
Interview practice
A colleague asks why they should bother with assertNever when they could just add a default: throw new Error("unknown kind") to every switch. What does the never version give them that the plain throw doesn’t?
Answer
Both throw at runtime when an unexpected value arrives, so the runtime behaviour is the same. The difference is when you find out. A plain throw in default compiles whether or not every member is handled, so adding giftcard to the union produces no errors, and you discover the missing case when a gift card payment hits that line in production. With assertNever(method), the parameter type never means the call only compiles if every member has already been handled. Adding giftcard turns every incomplete switch into a compile error that names the missing member. So the never version moves the failure from runtime to build time and gives you a to-do list of every place that needs updating, which is the point of having a union in the first place. I’d keep the throw inside assertNever for the runtime case, and include the value in the message so the log shows what arrived.