•11 min read

How TypeScript Exhaustive Checking Eliminates Entire Bug Categories

How TypeScript Exhaustive Checking Eliminates Entire Bug Categories
Type Safety

never type

Click to reveal
Type Safety
TypeScript's bottom type - the type with no possible values. Used as a return type for functions that never return normally (always throw or infinite loop).

never type

Type Safety

exhaustive check

Click to reveal
Type Safety
A pattern using a `switch` with a `never`-typed default case. TypeScript errors at compile time if you add a new union member but forget to handle it in any switch.

exhaustive check

Type Safety

type guard

Click to reveal
Type Safety
A runtime predicate function or check that narrows a union type to one of its members. Used with `in` operator, `typeof`, or custom predicates with `is` keyword.

type guard

Type Safety

discriminated union

Click to reveal
Type Safety
A union where every member shares a common literal-typed property (the 'tag'). TypeScript narrows the type based on that property without explicit guards.

discriminated union

How TypeScript Exhaustive Checking Eliminates Entire Bug Categories

TypeScript catches a lot of bugs at compile time. But there is one pattern that turns it from a helpful assistant into a relentless auditor: exhaustive checking with the never type. The difference is that a missing case in a switch does not produce a vague "this might be undefined" warning - it produces a hard error on the exact line you forgot.

Audio Briefing
0:00 / 0:00

The Problem It Solves

Every enum-like switch statement is a ticking time bomb in a codebase that evolves. You write a handler for three cases. Three months later someone adds a fourth variant. It compiles. Tests pass. Production silently ignores the new case. No error until a customer reports it.

Exhaustive checking is the fix. It makes the compiler your reviewer, not just your spellchecker.

Advertisement

The never Type in Practice

TypeScript's never type represents values that never occur. A function that always throws returns never. The exhaustive-checking pattern exploits that: when TypeScript thinks a branch has type never, but you are trying to assign its result to something else, it errors - every single time.

Here is the core pattern:

type Status = "idle" | "loading" | "success" | "error";

function renderStatus(status: Status): string {
  switch (status) {
    case "idle":
      return "Waiting...";
    case "loading":
      return "Loading...";
    case "success":
      return "Done.";
    case "error":
      return "Something went wrong.";
    default:
      // The exhaustiveness check
      const _exhaustive: never = status;
      return _exhaustive;
  }
}

The assignment const _exhaustive: never = status is unreachable by design. If you add a new Status member and forget to add a case above, TypeScript assigns "new-status" to _exhaustive and immediately reports:

Type '"new-status"' is not assignable to type 'never'.

It does this at compile time. No tests needed for this particular bug category.

A Real-World Example

API Response Handler
type ApiResponse<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: ApiError };

function showResponse<T>(response: ApiResponse<T>) {
  switch (response.status) {
    case "idle":
      return <EmptyState />;
    case "loading":
      return <Spinner />;
    case "success":
      return <DataView data={response.data} />;
    case "error":
      return <ErrorBanner message={response.error.message} />;
    default:
      const _exhaustive: never = response;
      return _exhaustive;
  }
}

Each branch narrows the discriminated union correctly. If someone later adds "retry" to ApiResponse but forgets to add a case "retry" branch, the default case breaks at that exact line. No runtime surprise.

The Helper Function

Many codebases prefer a named function over inline variable declarations. It reads cleanly and gives a single place to inspect when something goes wrong:

function assertNever(
  x: never,
  message = "Unexpected exhaustive case"
): never {
  throw new Error(`${message}: ${JSON.stringify(x)}`);
}

Usage:

function processState(state: AppState) {
  switch (state.type) {
    case "init": return init(state);
    case "fetch": return fetchData(state);
    case "render": return render(state);
    default: return assertNever(state, "Unhandled AppState");
  }
}

The function's return type is never, so TypeScript refuses to compile the call if state is not actually never. That refusal is the bug signal.

Advertisement

Why Not Just a Default That Throws?

A naive approach is to throw from default without the never check:

default:
  throw new Error(`Unhandled status: ${status}`);

This looks similar but has a critical gap: it compiles silently with any new Status value. The runtime error fires only when that path is actually hit in production. The exhaustive check fires at compile time, before anyone deploys anything.

AspectNaive default throwExhaustive never check
Adding a new case
Compiles silently
Fails at compile time
Error detection
Surfaces at runtime in prod
Surfaces in editor, before commit
Type evolution
Easy to forget
Enforced by type system
IDE support
No signal
Highlights exact line

Use With Discriminated Unions

Exhaustive checking shines brightest with large discriminated unions. State machines, AST nodes, and event payloads are common targets:

type AuthEvent =
  | { kind: "login"; username: string }
  | { kind: "logout" }
  | { kind: "token_refresh"; token: string }
  | { kind: "session_expired"; reason: string };

function logAuthEvent(event: AuthEvent) {
  switch (event.kind) {
    case "login":
      console.log(`${event.username} logged in`);
      break;
    case "logout":
      console.log("User logged out");
      break;
    case "token_refresh":
      console.log(`Token refreshed, ends ${event.token.slice(-4)}`);
      break;
    case "session_expired":
      console.log(`Session expired: ${event.reason}`);
      break;
    default:
      const _exhaustive: never = event;
      return _exhaustive;
  }
}

Adding { kind: "mfa_challenge" } without a corresponding case breaks compilation immediately. This is the behavior you want in security-sensitive code where silently ignoring a case can mean silently bypassing a check.

Exhaustiveness in if/else Chains

function getRoleName(role: UserRole): string {
  if (role === "admin") return "Administrator";
  if (role === "editor") return "Content Editor";
  if (role === "viewer") return "Read-only Viewer";
  // Exhaustiveness check at the end
  const _exhaustive: never = role;
  return _exhaustive;
}

The pattern works identically in if/else chains. It reads naturally at the bottom of the chain as a "nothing else should reach here" sentinel.

Tooling Compatibility

  • TypeScript 5.x (all current versions) - the never assignment check has been stable since early versions. - Strict mode recommended: exhaustive checking relies on the compiler enforcing that an unreachable branch is actually never. It works without strict mode, but the signal is weaker in noImplicitAny-off mode. - ESLint: the @typescript-eslint/switch-exhaustiveness-check rule covers most discriminated-union cases without the manual never variable. Prefer the ESLint rule when available, keep the manual pattern for older toolchains. - IDEs: VSCode, WebStorm, and Zed all surface the mismatch inline at the assignment site - no build step required.

Limitations and Edge Cases

  • The never assignment trick does not work inside Map callbacks or ternary expressions directly - prefer a named helper function in those contexts. - When a switch covers a union inside a mapped type, the exhaustiveness check may generate false positives on property access. Split the check into a separate function to keep error messages readable. - Exhaustiveness checks add a small compile-time cost for very large unions (100+ variants). Significant only in monorepos with many such switch statements. - Does not replace tests for logic errors within a case - it only catches the missing case category.

Where to Apply This Pattern

The highest-leverage places for exhaustive checking in a typical codebase:

  • State machines (UI state, auth state, payment state) - every new state must be handled explicitly. - AST visitors or event handlers: adding a new node type or event must produce a new branch or a loud error. - API response union types - new response variants must surface a new render path. - CLI subcommand dispatchers - adding a new subcommand must add a handler or fail to compile.

Quick Reference

// Inline version (most common)
function handleStatus(status: Status): string {
  switch (status) {
    case "idle": return "Waiting";
    case "loading": return "Loading...";
    case "success": return "Done";
    case "error": return "Failed";
    default: { const _exhaustive: never = status; return _exhaustive; } }
}

// Helper function version (cleaner for large switches)
function assertNever(x: never, msg?: string): never {
  throw new Error(msg ?? `Unhandled case: ${JSON.stringify(x)}`);
}

// With discriminated union
type Tree =
  | { type: "leaf"; color: string }
  | { type: "branch"; children: Tree[] }
  | { type: "fruit"; kind: string };

function describeTree(tree: Tree): string {
  switch (tree.type) {
    case "leaf": return `Leaf: ${tree.color}`;
    case "branch":
      return `Branch with ${tree.children.length} children`;
    case "fruit": return `Fruit: ${tree.kind}`;
    default: return assertNever(tree);
  }
}

You Might Also Like

Test Your Knowledge

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement