Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 049 / 100

Exhaustiveness checks with never: lessons from production

Let the compiler tell you when you forgot a case.

Expona Admin6 min read

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.

typescript
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.

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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;:

typescript
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:

typescript
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 never makes 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 assertNever to 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles