TypeScript `never` in Practice: Exhaustive Checks, Impossible States, and Narrowing Dead Code
Master the never type for exhaustive checks, impossible state elimination, and compile-time guarantees. Learn practical patterns that catch bugs before they reach production.
TypeScript never in Practice: Exhaustive Checks, Impossible States, and Narrowing Dead Code
Most TypeScript runtime bugs stem from a single root cause: the compiler knows about code paths that should be impossible, but developers never asked it to enforce that knowledge. Teams write switch statements that "handle all cases" but silently break when a new variant arrives. State machines allow contradictory properties to coexist. Union type guards forget to check every branch, shipping the unchecked path straight to production.
The never type solves this problem by making impossible states unrepresentable and forgotten branches a compile-time error. When the compiler narrows a value's type to never, it means "this code cannot execute unless the type system has a hole." Leverage this signal correctly and entire categories of bugs disappear before the first test runs.
flowchart LR
A("Handle union cases") --> B("Forget a case")
B --> C("Runtime error in production")
style C stroke:#ef4444,fill:#450a0a,color:#fca5a5
The correct pattern forces the compiler to prove exhaustiveness at every branch point. When a new union variant arrives, the code refuses to compile until every handler accounts for it. The type system becomes a contract enforcer, not just documentation.
flowchart LR
A("Handle union cases") --> D("Compiler verifies exhaustiveness")
D --> E("New case breaks build immediately")
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Key Takeaways
- The
nevertype represents values that cannot exist; the compiler assigns it to code paths proven unreachable through type narrowing. - Exhaustive checks force compile-time errors when union types grow but handlers do not, catching missing cases before deployment.
- Impossible states become unrepresentable by designing types where contradictory properties cannot coexist, eliminating entire classes of validation logic.
- Control flow analysis narrows types to
neverin dead branches, enabling tree-shaking and exposing logic errors that runtime tests miss. - Distinguishing
never,void, andundefinedprevents subtle bugs:nevermeans "unreachable",voidmeans "no return value",undefinedmeans "optional value".
What Is the never Type and Why It Matters
The never type signals to the compiler that a value can never exist. When TypeScript narrows a discriminated union through control flow analysis and eliminates all possible variants, the remaining type becomes never. This is not an error—it is proof that the code path is unreachable given the constraints.
The distinction matters because never is the only type that cannot be assigned to or from any other type except itself. A function returning never cannot complete normally; it must throw an exception or loop forever. A variable typed as never can only arise from type narrowing that has excluded every possible value.
Consider a union of string literals:
type Status = "idle" | "loading" | "success" | "error";
function handleStatus(status: Status): void {
if (status === "idle") {
return;
}
if (status === "loading") {
return;
}
if (status === "success") {
return;
}
if (status === "error") {
return;
}
// At this point, status has type `never`
// because all possible values have been eliminated
const _exhaustiveCheck: never = status;
}The variable _exhaustiveCheck exists solely to force a compile-time error if a developer adds a fifth status variant but forgets to handle it. Without this check, the code compiles successfully and the new case falls through to undefined behavior at runtime.
The power here is mechanical verification. The developer does not need to remember to update every switch statement or if-else chain when the union grows. The compiler refuses to build until the gap is addressed. This pattern scales to codebases with hundreds of union types and thousands of branching points.
In other words, never transforms runtime fragility into compile-time guarantees. The cost is a single line of boilerplate per exhaustive check. The return is elimination of an entire failure mode.
Exhaustive Checks in Switch Statements and Conditional Branches
Exhaustive checks prevent the most common source of production bugs in systems with evolving domain models. When a union type represents states, actions, or variants that grow over time, every handler must account for every member. The naive approach relies on developer discipline. The correct approach leverages never to make incomplete handling a type error.
The pattern works by creating a function that accepts only never. When control flow reaches this function, the compiler verifies that the input type has been narrowed to never—meaning all reachable cases have been handled. If a case remains, the type is not never, and the assignment fails.
function assertUnreachable(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
type ApiResponse =
| { type: "success"; data: unknown }
| { type: "error"; message: string }
| { type: "loading" };
function processResponse(response: ApiResponse): void {
switch (response.type) {
case "success":
console.log("Data:", response.data);
return;
case "error":
console.error("Error:", response.message);
return;
case "loading":
console.log("Loading...");
return;
default:
assertUnreachable(response);
}
}When a fourth response type arrives—say, { type: "timeout" }—the compiler flags the default branch immediately. The response variable is no longer never; it is the unhandled timeout variant. The error message points directly to the missing case.
flowchart TD
A("Switch on union type") --> B("Handle each case")
B --> C{"All cases handled?"}
C -->|Yes| D("Type narrows to never")
C -->|No| E("Compiler error at default")
D --> F("assertUnreachable accepts never")
E --> G("Points to unhandled variant")
style D stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style G stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
This same pattern applies to if-else chains, early returns, and nested conditionals. The key is placing the exhaustiveness assertion at the point where all valid paths have exited. The remaining type must be never, or the code does not compile.
The runtime behavior of assertUnreachable is irrelevant. The function should never execute in correctly typed code. Its purpose is compile-time enforcement. Some teams use throw to fail loudly if a type hole appears at runtime; others use return to satisfy the never signature without introducing exceptions.
This distinction is critical. Exhaustive checks are not defensive programming—they are contract enforcement. The compiler guarantees the contract holds unless unsafe type assertions or untyped boundaries introduce holes. At those boundaries, runtime validation remains necessary. Everywhere else, the type system proves correctness before deployment.
Building a Type-Safe State Machine with never
State machines are the canonical use case for exhaustive checks because they combine growing variant sets with strict transition rules. A naive implementation couples state types to handler logic through naming conventions and documentation. The correct implementation makes illegal transitions unrepresentable and missing handlers a compile error.
The pattern begins with a discriminated union where each state is a distinct type. The discriminant—commonly a type or status field—allows the compiler to narrow the union in switch statements. Each state carries only the data valid for that state, making contradictory properties impossible.
type ConnectionState =
| { status: "disconnected" }
| { status: "connecting"; startTime: number }
| { status: "connected"; socket: WebSocket; connectedAt: number }
| { status: "reconnecting"; attempt: number; lastError: Error };
type ConnectionEvent =
| { type: "CONNECT" }
| { type: "CONNECTED"; socket: WebSocket }
| { type: "DISCONNECT" }
| { type: "ERROR"; error: Error };
function transition(
state: ConnectionState,
event: ConnectionEvent
): ConnectionState {
switch (state.status) {
case "disconnected":
if (event.type === "CONNECT") {
return { status: "connecting", startTime: Date.now() };
}
return state;
case "connecting":
if (event.type === "CONNECTED") {
return {
status: "connected",
socket: event.socket,
connectedAt: Date.now()
};
}
if (event.type === "ERROR") {
return { status: "reconnecting", attempt: 1, lastError: event.error };
}
return state;
case "connected":
if (event.type === "DISCONNECT") {
state.socket.close();
return { status: "disconnected" };
}
if (event.type === "ERROR") {
state.socket.close();
return { status: "reconnecting", attempt: 1, lastError: event.error };
}
return state;
case "reconnecting":
if (event.type === "CONNECTED") {
return {
status: "connected",
socket: event.socket,
connectedAt: Date.now()
};
}
if (event.type === "DISCONNECT") {
return { status: "disconnected" };
}
return state;
default:
assertUnreachable(state);
}
}This design makes several guarantees. First, the socket property exists if and only if the state is connected. Attempting to access state.socket in the disconnected case produces a compile error. Second, every state-event combination either returns a new state or returns the current state unchanged, making no-op transitions explicit. Third, adding a fifth state—say, "suspended"—breaks the build at every transition call until handlers are added.
The implication here is that state machines designed this way cannot enter invalid states at compile time. Runtime validation becomes necessary only at system boundaries where untyped data enters. Internal transitions are provably correct.
For complex state machines with dozens of states and hundreds of transitions, this pattern scales by breaking the transition function into smaller handlers. Each handler accepts a single state type and returns the next state, with the top-level function delegating by discriminant. The exhaustiveness check remains at the top level, ensuring no state is forgotten even as the codebase grows.
Impossible States: Making Invalid Data Unrepresentable
The failure mode here is subtle but expensive. Most validation logic exists because data structures allow contradictory combinations of properties. A user object with both isGuest: true and memberId: string forces every consumer to validate which property wins. A request with both method: "GET" and a non-null body crashes at runtime when the HTTP library rejects it.
The correct pattern eliminates validation by designing types where invalid combinations cannot be constructed. Discriminated unions replace boolean flags; each variant carries only the properties valid for that state. The compiler prevents construction of impossible values, and the type system proves that consumers never receive them.
// Broken: allows contradictory states
type BrokenUser = {
isGuest: boolean;
memberId?: string;
email?: string;
guestToken?: string;
};
// Correct: invalid combinations are unrepresentable
type User =
| { type: "guest"; guestToken: string }
| { type: "member"; memberId: string; email: string };
function displayUser(user: User): string {
switch (user.type) {
case "guest":
return `Guest (${user.guestToken})`;
case "member":
return `${user.email} (ID: ${user.memberId})`;
default:
assertUnreachable(user);
}
}The difference is immediate. The broken design requires runtime checks: "Is this user a guest or a member? If guest, does guestToken exist? If member, do both memberId and email exist?" The correct design makes these questions impossible. A User value is always exactly one variant, and each variant contains exactly the properties it needs.
flowchart LR
A("Define user properties") --> B("Create discriminated union")
B --> C("Each variant has valid properties")
C --> D("Invalid combinations cannot be constructed")
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This matters because validation code has two failure modes: it can fail to catch invalid states, and it can drift out of sync with the data structure. When a new boolean flag arrives, every validation site must update or silent bugs emerge. When the type system enforces validity, the validation code does not exist. There is nothing to drift.
Real-world examples include HTTP request types (GET requests have no body, POST requests have required content-type), async operation states (pending operations have no result or error, fulfilled operations have a result, rejected operations have an error), and resource lifecycle states (loading resources have no data, loaded resources have data and a timestamp).
The pattern applies recursively. A discriminated union variant can itself contain discriminated unions, building arbitrarily complex constraints without validation logic. The cost is slightly more verbose type definitions. The return is elimination of an entire class of bugs and the maintenance burden of validation code.
Control Flow Narrowing and Dead Code Elimination
Control flow analysis narrows types based on runtime checks. When an if-statement tests a discriminant, the compiler knows which union variants remain possible in each branch. Continue narrowing through nested conditions and the type eventually reaches never, proving the branch is unreachable.
The compiler uses this information to eliminate dead code during tree-shaking. If a branch's type is never, the branch cannot execute, and the code it contains can be safely removed from the production bundle. This is not speculation—the type system proves the code is unreachable.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; sideLength: number };
function getArea(shape: Shape): number {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
}
if (shape.kind === "square") {
return shape.sideLength ** 2;
}
// TypeScript knows shape is `never` here
// This branch is provably unreachable
const _exhaustive: never = shape;
return _exhaustive;
}When a developer adds a third shape—say, { kind: "triangle"; base: number; height: number }—the function no longer compiles. The final branch receives triangle instead of never, and the assignment fails. The error message points directly to the unhandled case.
This pattern catches logic errors that unit tests miss. If a developer writes:
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
}
// Accidentally forgot to check "square"
return 0;The code compiles because the return type is number. Tests might pass if they only cover circles. The bug ships to production and manifests when the first square arrives. With exhaustive checks, the error is immediate. The compiler proves that shape could still be square at the return statement, so assigning it to never fails.
The implication here is that exhaustiveness checking and control flow narrowing work together to prove correctness. Narrowing eliminates variants in each branch. Exhaustiveness checks verify that all variants have been eliminated by the time control flow reaches the end. The result is compile-time proof that the function handles every case.
This proof extends to complex nested conditions. For discriminated unions with nested discriminants, the compiler tracks the narrowing through multiple levels. For parallel branches (like separate if-statements that together cover all cases), the compiler tracks which variants remain possible after each check. The type system's control flow analysis is sound—it never claims a type is never unless that branch is genuinely unreachable.
never vs void vs undefined: When to Use Each
The three types represent different concepts that developers frequently conflate. Misunderstanding when to use each produces subtle bugs: functions that should never return silently return undefined, optional parameters that should accept undefined reject it, unreachable branches that should fail at compile time pass silently.
The distinctions:
neverrepresents values that cannot exist. A function returningnevercannot return normally—it must throw an exception, enter an infinite loop, or terminate the process.voidrepresents the absence of a return value. A function returningvoidcompletes normally but does not produce a value. It can still execute side effects.undefinedis a value. A function returningundefinedexplicitly returns the valueundefined. A parameter typedundefinedcan receive the valueundefined.
flowchart LR
A["Type system concepts"] --> B("never: no value can exist")
A --> C("void: no return value")
A --> D("undefined: explicit value")
B --> E("Functions that throw or loop forever")
C --> F("Functions with side effects only")
D --> G("Optional parameters and nullable types")
style B stroke:#7c9cf0,fill:#142544,color:#eaf2ff
style C stroke:#7c9cf0,fill:#142544,color:#eaf2ff
style D stroke:#7c9cf0,fill:#142544,color:#eaf2ff
Practical usage:
// never: function cannot return normally
function fail(message: string): never {
throw new Error(message);
}
function infiniteLoop(): never {
while (true) {
// process events forever
}
}
// void: function returns but produces no value
function logMessage(message: string): void {
console.log(message);
// implicit return undefined, but type is void
}
// undefined: explicit value
function findUser(id: string): User | undefined {
const user = database.get(id);
return user ?? undefined; // explicitly returning the value undefined
}
// Parameter types
function handleEvent(
callback: () => void, // callback can return anything, return value is ignored
cleanup?: () => undefined // cleanup must explicitly return undefined if provided
): void {
callback();
cleanup?.();
}The confusion arises because void functions can include return; statements or implicit return undefined; at the end. The type system treats these as equivalent—the function completes normally without a value. But the function's return type remains void, not undefined. The distinction matters for assignability: a function returning void can be assigned to a function returning undefined, but not vice versa without type assertions.
For callbacks, void return types provide flexibility. A callback typed () => void accepts functions that return any type, discarding the return value. A callback typed () => undefined requires functions that explicitly return undefined. Most callback signatures should use void unless they specifically need to enforce that the callback produces no return value.
For exhaustiveness checks, never is the only correct choice. The check exists to prove that a code path cannot execute. Using void or undefined silently accepts unreachable branches, defeating the purpose.
This distinction is critical. When TypeScript narrows a type to never, it is proving unreachability through type analysis. Treating never as interchangeable with void or undefined discards that proof and reintroduces the bugs exhaustiveness checks exist to prevent.
Real-World Patterns: API Response Handlers and Union Type Guards
API response handlers are a production environment where exhaustiveness checks directly prevent customer-facing bugs. APIs evolve: new status codes arrive, response formats change, error variants multiply. A handler that does not account for every variant ships silent failures—requests that succeed but produce no output, errors that vanish without logging, states that hang indefinitely waiting for an update that never comes.
The correct pattern models responses as discriminated unions and enforces exhaustive handling at every integration point. When the API adds a variant, every handler breaks at compile time until updated.
type ApiResult<T> =
| { status: "success"; data: T; timestamp: number }
| { status: "error"; code: string; message: string }
| { status: "unauthorized"; redirectUrl: string }
| { status: "rate_limited"; retryAfter: number };
async function fetchUser(id: string): Promise<ApiResult<User>> {
const response = await fetch(`/api/users/${id}`);
if (response.status === 200) {
const data = await response.json();
return { status: "success", data, timestamp: Date.now() };
}
if (response.status === 401) {
const { redirectUrl } = await response.json();
return { status: "unauthorized", redirectUrl };
}
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get("Retry-After") ?? "60");
return { status: "rate_limited", retryAfter };
}
const { code, message } = await response.json();
return { status: "error", code, message };
}
function handleUserResult(result: ApiResult<User>): void {
switch (result.status) {
case "success":
displayUser(result.data);
updateCache(result.data, result.timestamp);
return;
case "error":
logError(result.code, result.message);
showErrorToast(result.message);
return;
case "unauthorized":
clearSession();
redirectTo(result.redirectUrl);
return;
case "rate_limited":
scheduleRetry(result.retryAfter);
showRateLimitNotice(result.retryAfter);
return;
default:
assertUnreachable(result);
}
}When the API introduces a maintenance status for scheduled downtime, the TypeScript compiler flags every call to handleUserResult. The developer must decide how to handle maintenance mode—show a banner, redirect to a status page, queue requests for retry—before the code compiles. Without exhaustive checks, the new status falls through to undefined behavior, and the bug surfaces in production when maintenance mode activates.
flowchart LR
A("API returns response") --> B("Parse into ApiResult union")
B --> C("Switch on status discriminant")
C --> D("Handle each case")
D --> E("assertUnreachable proves exhaustiveness")
style E stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
The same pattern applies to union type guards. When a function accepts multiple input types and dispatches behavior based on the runtime type, exhaustive checks ensure every type is handled:
type Input = string | number | boolean | object;
function processInput(input: Input): string {
if (typeof input === "string") {
return input.toUpperCase();
}
if (typeof input === "number") {
return input.toFixed(2);
}
if (typeof input === "boolean") {
return input ? "yes" : "no";
}
if (typeof input === "object") {
return JSON.stringify(input);
}
assertUnreachable(input);
}When the Input union grows to include bigint, the function does not compile until a handler is added. The exhaustiveness check catches the gap immediately, before tests or code review.
For large codebases with dozens of API endpoints and hundreds of response handlers, this pattern prevents an entire class of integration bugs. The type system enforces consistency across the codebase. If one handler forgets a case, the build fails. If the API contract changes, every handler receives the update simultaneously.
Frequently Asked Questions
What happens if I bypass never checks with type assertions?
Type assertions like as any or value as never disable exhaustiveness checking at that location. The compiler trusts the assertion and suppresses errors, reintroducing the same bugs exhaustiveness checks exist to prevent. Use assertions only at untyped boundaries where external data enters the system.
Can I use never for optional function parameters?
No, never represents values that cannot exist; optional parameters represent values that might not be provided. Use undefined for optional parameters: function foo(x?: number) or function foo(x: number | undefined). A parameter typed never cannot be called with any argument.
How do I handle unions with overlapping discriminants?
When two variants share the same discriminant value, the compiler cannot narrow the union. Refactor the union so each variant has a unique discriminant, or add secondary discriminants to distinguish cases. The error message will point to the ambiguous branch.
Why does my exhaustiveness check fail even though I handled all cases?
The compiler's control flow analysis is path-sensitive. If you use early returns or nested conditions, the compiler tracks which variants remain possible at each point. Check that every variant is eliminated before the exhaustiveness assertion. Add explicit returns or break statements after each case.
Should I use never or void for functions that throw exceptions?
Use never. Functions returning void can complete normally even if they include throw statements in some branches. Functions returning never must never return normally—they either throw in all branches or loop forever. The distinction tells callers whether the function might return.
Common Pitfalls and How to Debug never Type Errors
The most common error developers encounter is "Type 'X' is not assignable to type 'never'". This message indicates that the compiler expected a value to be never (meaning all possible types have been eliminated through narrowing) but instead found a concrete type. This is not a compiler bug—it is the compiler proving that the exhaustiveness check has failed.
The fix is always the same: add a handler for the unhandled case. If the error occurs in a default branch, a case is missing from the switch statement. If it occurs in an assertUnreachable call, a conditional branch has not been added for the new variant.
The second pitfall is overusing never where void or undefined is correct. Functions that complete normally should return void, not never. Parameters that accept the value undefined should be typed undefined, not never. Using never in these contexts produces confusing type errors because the compiler treats never as the bottom type—no value can be assigned to it except through narrowing.
The third pitfall is mixing tagged unions with non-exhaustive switch statements. If the discriminant is a string type, not a union of string literals, the compiler cannot verify exhaustiveness. The fix is to use literal types for discriminants:
// Broken: discriminant is `string`, not a union of literals
type BrokenMessage = {
type: string;
payload: unknown;
};
// Correct: discriminant is a union of literals
type Message =
| { type: "connect"; clientId: string }
| { type: "disconnect"; reason: string }
| { type: "data"; payload: unknown };For complex unions with dozens of variants, the compiler's error messages can overwhelm. The strategy is to comment out the exhaustiveness check temporarily, add a single case branch for the first missing variant, then uncomment the check. Repeat until all variants are handled. The compiler will guide you through each missing case one at a time.
When debugging unexpected never assignments, use the TypeScript playground or an IDE with inline type display to see what type the compiler has inferred at each location. If a value has type never where it should not, the control flow analysis has narrowed it incorrectly—likely because a branch condition is too broad or a type guard is missing.
That covers the essential patterns for leveraging never in production TypeScript. Apply exhaustive checks at every union type handler, design types where invalid states cannot be constructed, and let the compiler prove correctness before deployment. The difference is immediate: entire categories of runtime bugs vanish, and evolving domain models no longer break silently at integration boundaries.