How TypeScript Exhaustive Checking Eliminates Entire Bug Categories

Table of Contents(13 sections)
never type
never type
exhaustive check
exhaustive check
type guard
type guard
discriminated union
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.
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.
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.
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.
| Aspect | Naive default throw | Exhaustive 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
neverassignment check has been stable since early versions. - Strict mode recommended: exhaustive checking relies on the compiler enforcing that an unreachable branch is actuallynever. It works without strict mode, but the signal is weaker innoImplicitAny-off mode. - ESLint: the@typescript-eslint/switch-exhaustiveness-checkrule covers most discriminated-union cases without the manualnevervariable. 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
neverassignment trick does not work insideMapcallbacks or ternary expressions directly - prefer a named helper function in those contexts. - When aswitchcovers 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 suchswitchstatements. - 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
- JavaScript to Luau: The Complete Roblox Scripting Cheat Sheet (2026)
- const vs Immutability in JavaScript: The Binding, Not the Value
- How to Get the Correct Y Position of an Element in ChatGPT UI (2026 Guide)
- Stop Using Promise.race for Fallbacks: The Case for Promise.any and AggregateError
Test Your Knowledge
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

TypeScript Generics: Advanced Patterns for Type-Safe APIs
Master advanced TypeScript generics patterns: conditional types, mapped types, template literal types, branded types, discriminated unions, and building type-safe production libraries.
Read more
Advanced Mypy Strict Mode Patterns for Production Python
Master advanced mypy strict type checking patterns using ParamSpec, TypeVarTuple, Protocols, and custom type guards in large Python codebases.
Read more
TypeScript Alone Isn't Enough: End-to-End Type Safety with Zod in Next.js
TypeScript evaporates at runtime. Learn why static types fail at your API and Server Action boundaries, and how Zod delivers infallible schema validation and inferred typing.
Read more