Exhaustiveness checks with never
When you refactor a union type by adding a new variant, your code silently accepts it—until production breaks. Exhaustiveness checks with never let the TypeScript compiler catch these gaps before you ship. By assigning the default case to a variable typed as never, you turn missing cases into compile errors, not runtime surprises.
Switch over a union
Let's start with a real example. I built a payment processing system for an e-commerce platform where each order has a Status—one of several well-defined states.
type Status = 'pending' | 'paid' | 'failed' | 'refunded';
function label(s: Status): string {
switch (s) {
case 'pending':
return 'Awaiting payment';
case 'paid':
return 'Payment received';
case 'failed':
return 'Payment failed';
case 'refunded':
return 'Refunded to customer';
}
}
This works fine today. But six months later, your product team adds a new status: 'disputed'. The type becomes type Status = 'pending' | 'paid' | 'failed' | 'refunded' | 'disputed'. Your label function will now receive 'disputed' but return undefined—a silent failure.
Without exhaustiveness checks, you're relying on tests or code review to catch this. With a large codebase, that's optimistic.
Assign the default case to never
The fix is simple: instead of having a default case that does nothing, assign it to a variable typed as never.
type Status = 'pending' | 'paid' | 'failed' | 'refunded';
function label(s: Status): string {
switch (s) {
case 'pending':
return 'Awaiting payment';
case 'paid':
return 'Payment received';
case 'failed':
return 'Payment failed';
case 'refunded':
return 'Refunded to customer';
default:
const x: never = s;
return x;
}
}
When the switch covers all cases, the default branch is unreachable and s is narrowed to never. This compiles perfectly.
The never type represents values that can never exist. By assigning s to it, you're telling TypeScript: "at this point, s must be impossible." If you've handled all cases correctly, it is.
Adding a variant breaks the build
Now add that new status:
type Status = 'pending' | 'paid' | 'failed' | 'refunded' | 'disputed';
function label(s: Status): string {
switch (s) {
case 'pending':
return 'Awaiting payment';
case 'paid':
return 'Payment received';
case 'failed':
return 'Payment failed';
case 'refunded':
return 'Refunded to customer';
default:
const x: never = s;
return x;
}
}
TypeScript now errors:
Type 'string' is not assignable to type 'never'.
Or more specifically:
Type '"disputed"' is not assignable to type 'never'.
Your build fails. You can't merge the PR until you handle 'disputed'. This is exactly what you want: the compiler forces you to update every place that switches over Status.
I've used this pattern across multiple codebases—ride-hailing apps with order states, trading platforms with transaction statuses, SaaS products with user roles. Every time I add a variant, the compiler instantly flags every incomplete switch, saving me hours of debugging.
Safer refactors
Exhaustiveness checks protect more than just switches. They work anywhere you dispatch on a union.
If-else chains:
type UserRole = 'admin' | 'moderator' | 'user';
function getPermissionLevel(role: UserRole): number {
if (role === 'admin') return 3;
if (role === 'moderator') return 2;
if (role === 'user') return 1;
const x: never = role;
return x;
}
Add a new role? The compiler complains immediately.
Conditional render functions in React:
type PageState = 'loading' | 'success' | 'error';
function renderPage(state: PageState) {
switch (state) {
case 'loading':
return <Spinner />;
case 'success':
return <Content />;
case 'error':
return <ErrorBanner />;
default:
const x: never = state;
return x;
}
}
If you add 'idle' or 'retry', you'll know before you test.
Discriminated unions with objects:
type Event =
| { type: 'user.created'; userId: string }
| { type: 'user.deleted'; userId: string }
| { type: 'payment.completed'; orderId: string };
function handleEvent(event: Event): void {
switch (event.type) {
case 'user.created':
console.log('New user:', event.userId);
break;
case 'user.deleted':
console.log('User removed:', event.userId);
break;
case 'payment.completed':
console.log('Payment for:', event.orderId);
break;
default:
const x: never = event;
throw new Error(`Unhandled event: ${x}`);
}
}
This is especially useful with Kafka or event-driven systems. I've used it in a ride-hailing platform where adding a new event type (like ride.cancelled) required updating handlers across multiple services. The exhaustiveness check ensured I didn't miss any handlers.
Helper function for exhaustiveness checks:
You can also extract a helper:
function assertNever(x: never): never {
throw new Error(`Exhaustiveness check failed with value: ${x}`);
}
function label(s: Status): string {
switch (s) {
case 'pending': return 'Awaiting payment';
case 'paid': return 'Payment received';
case 'failed': return 'Payment failed';
case 'refunded': return 'Refunded to customer';
default:
return assertNever(s);
}
}
This is cleaner in large projects and provides a runtime safety net (though the real value is the compile-time check).
Common mistakes
Forgetting the default case entirely:
function label(s: Status): string {
switch (s) {
case 'pending': return 'Awaiting payment';
case 'paid': return 'Payment received';
// Missing 'failed' and 'refunded'
}
}
TypeScript won't error here—functions can return undefined. Always add the default: const x: never = s; line to catch this.
Using default: return s;:
default:
return s; // TypeScript allows this silently
This doesn't trigger exhaustiveness checks. You need explicit never assignment.
Relying on exhaustiveness without TypeScript:
JavaScript has no never type. If you're writing plain JavaScript, you won't get compile-time checks. Use JSDoc or migrate to TypeScript if exhaustiveness matters.
Exhaustiveness in optional types:
type Status = 'pending' | 'paid' | undefined;
function label(s: Status): string {
switch (s) {
case 'pending': return 'Awaiting payment';
case 'paid': return 'Payment received';
default:
const x: never = s; // s is 'undefined', not 'never'
return x;
}
}
Handle undefined explicitly or narrow the type before switching.
Key takeaways
- Exhaustiveness checks catch refactors early. Assigning the default case to
nevermakes TypeScript enforce that you've handled all union variants. - This works with switches, if-else chains, and discriminated unions. Use it anywhere you dispatch on a union type.
- The cost is minimal. One line of code per switch provides compiler-enforced safety across your entire codebase.
- Combine with extract helpers for readability. Use
assertNeverto centralize exhaustiveness logic and provide runtime error messages. - It scales to event-driven systems. In platforms with many event types (Kafka, pub-sub, webhooks), exhaustiveness checks prevent silent drops of newly added event variants.






Comments
Loading comments…