Exhaustive switches: the default clause that hides new states
A default clause is a promise that you have thought about every future case. Almost nobody means to make that promise.
The problem
You have modelled payment outcomes as a discriminated union, and a switch handles each one. The switch ends with a default that returns a generic fallback message, because the compiler complained about a missing return.
Six months later somebody adds a fourth outcome: refunded. They update the union, run the test suite, and ship.
type PaymentResult =
| { status: 'paid'; receiptId: string }
| { status: 'declined'; reason: string }
| { status: 'pending' }
| { status: 'refunded'; refundId: string }; // added today
function describe(result: PaymentResult): string {
switch (result.status) {
case 'paid': return `Receipt ${result.receiptId}`;
case 'declined': return `Declined: ${result.reason}`;
case 'pending': return 'Payment pending';
default: return 'Unknown payment status';
}
}
console.log(describe({ status: 'refunded', refundId: 'rf_1' }));PREDICT FIRST
What does the compiler say about the newly added refunded variant?
Commit to an answer before you read on. Being wrong here is the part that sticks.
Why it behaves that way
Inside case "paid", TypeScript narrows result to the paid variant. By the time control reaches default, it has narrowed away every variant the switch names — so in the three-variant version, result there had type never.
That never is the useful signal, and the fallback throws it away. default: return "Unknown" accepts a value of any type, so it keeps compiling no matter how the union grows.
The fix is to make the default clause demand a never. A helper whose parameter is typed never can only be called where the compiler has proven nothing remains. Add a fourth variant, and the call site stops compiling with a message that names the variant you forgot.
Keep the throw inside the helper as well. Types describe the code you compiled; a value parsed from JSON at runtime can still carry a status nobody modelled.
function assertNever(value: never): never {
throw new Error(`Unhandled payment status: ${JSON.stringify(value)}`);
}
function describe(result: PaymentResult): string {
switch (result.status) {
case 'paid': return `Receipt ${result.receiptId}`;
case 'declined': return `Declined: ${result.reason}`;
case 'pending': return 'Payment pending';
// Argument of type '{ status: "refunded"; refundId: string; }'
// is not assignable to parameter of type 'never'.
default: return assertNever(result);
}
}What the change buys you
| Behaviour | Before | After |
|---|---|---|
| Adding a variant to the union | Compiles; the new state hits the fallback | Compile error naming the unhandled variant |
| A refunded payment at runtime | Renders "Unknown payment status" | Renders the case you were forced to write |
| An unmodelled status arriving from an API | Silently rendered as unknown | Throws with the offending value in the message |
| Deleting a variant | Dead case sits unnoticed | Unreachable case flagged by the compiler |
Variations worth trying
- Drop the
defaultclause entirely and enablenoImplicitReturns. You get exhaustiveness for functions with an annotated return type, but no error message that names the missing variant. - Use the same trick on
if/else ifchains: the finalelsecallsassertNeveron the narrowed value. - Apply it to string-literal state machines, Redux-style action unions, and parser node kinds — anywhere a closed set of cases is likely to grow.
- Try it on a union of about twenty variants and notice that the compile error points at the switch, not at the union. Add a comment on the union pointing back at every exhaustive consumer.
TRAIN IT
Repair a switch that forgets a state
Predict the output, choose between a fallback, an explicit case and an exhaustiveness check, then watch which of the four tests each one passes.
Open the workout