10.2 Exhaustiveness Checking with never
Right, so you’ve built this beautiful, type-safe universe with your discriminated union. You’ve handled every known variant in your switch or if/else chain. You feel invincible. The compiler is happy. Life is good.
Then your product manager slides a ticket across your desk: “Hey, can we add a new type of ActionResult for when the user’s session times out?” And just like that, your beautiful, type-safe universe has a crack in its foundation. You add the new { type: 'session_timeout' } variant to the type, you deploy, and… everything explodes at runtime in a hundred different places because you, my brilliant friend, forgot to update one of those switch statements handling ActionResult.
This is why we need exhaustiveness checking. It’s our automated, compile-time safety net against our own forgetfulness. And the secret weapon is the never type.
The never type: It’s not just philosophical
Think of the never type as TypeScript’s way of saying “this should not exist here.” A value of type never can’t be observed because it represents something that should never, ever occur. It’s the type of a function that always throws an error, or the type of a variable under a type guard that proves it’s impossible.
We can weaponize this property. At the end of a supposedly exhaustive switch statement, if we’ve handled every case, the value passed to switch should have no possible type left other than never. So, we can write a helper function whose job is to take a value of type never and… do something.
// This is your new best friend. Put it in a utilities file.
function assertNever(value: never): never {
throw new Error(
`Unhandled discriminated union member: ${JSON.stringify(value)}`
);
}
This function screams, loudly and at runtime, if it’s ever actually called. But the magic happens at compile time.
Putting assertNever to Work
Let’s see it in action with a classic example.
type ActionResult =
| { type: 'success'; data: string }
| { type: 'error'; code: number }
| { type: 'loading'; };
function handleAction(action: ActionResult) {
switch (action.type) {
case 'success':
console.log(`Got data: ${action.data}`);
break;
case 'error':
console.error(`Error ${action.code}`);
break;
case 'loading':
console.log('Loading...');
break;
default:
// We think we've handled everything, so 'action' should be never here.
assertNever(action);
}
}
Right now, this is perfect. The TypeScript compiler knows that after the three case statements, action has no possible type left. It has been narrowed to never, which is exactly what assertNever expects. The code compiles without a peep.
Now, watch what happens when we add a new variant { type: 'session_timeout' } to the ActionResult type but don’t update the handleAction function.
type ActionResult =
| { type: 'success'; data: string }
| { type: 'error'; code: number }
| { type: 'loading'; }
| { type: 'session_timeout'; }; // NEW VARIANT
function handleAction(action: ActionResult) {
switch (action.type) {
case 'success':
console.log(`Got data: ${action.data}`);
break;
case 'error':
console.error(`Error ${action.code}`);
break;
case 'loading':
console.log('Loading...');
break;
default:
// π₯ COMPILER ERROR! π₯
// Argument of type '{ type: "session_timeout"; }' is not assignable to parameter of type 'never'.
assertNever(action);
}
}
Boom. The compiler catches the error immediately. The action in the default clause is now of type { type: 'session_timeout' }, not never. And you can’t pass that to a function that expects never. It’s a brilliantly straightforward and effective compiler error that forces you to handle the new case. It turns a silent runtime bug into a loud compile-time error, which is the best kind of error.
Beyond switch Statements
While switch is the most common place for this, the pattern works anywhere you can narrow a union. An if/else chain can end with a call to assertNever.
function handleActionWithIf(action: ActionResult) {
if (action.type === 'success') {
console.log(action.data);
} else if (action.type === 'error') {
console.error(action.code);
} else if (action.type === 'loading') {
console.log('Loading...');
} else {
// The 'else' clause narrows 'action' to any remaining types.
// If the union is exhaustive, it's never. If not, we get our error.
assertNever(action);
}
}
A Pitfall: The Empty Default Clause
This is where most people screw it up. You must have the default clause for this to work. Simply omitting it means the compiler will just silently ignore the unhandled case. The other classic mistake is putting a default clause that does something else, which completely defeats the purpose.
// β DON'T DO THIS. This is useless.
default:
// This swallows the error! The new 'session_timeout' variant will just quietly end up here.
console.log('Unknown action type');
// β DON'T DO THIS EITHER. Also useless.
default:
const _exhaustiveCheck: never = action; // This will cause the same error...
break; // ...but then you've just written a weird no-op line.
// β
DO THIS. This is the way.
default:
assertNever(action);
The second bad example does cause a compiler error, but it’s clunky and doesn’t give you a clear runtime error message if something somehow gets through. assertNever is the clean, established, and effective pattern. Use it.
Make this pattern a reflex. Every time you write a discriminated union, write a switch statement against it. Let the compiler guide you to handle all the cases, and then slam the door shut with assertNever(action) in the default. Itβs a tiny amount of code for an enormous amount of protection. Itβs not just clever; itβs professional.