TypeScript `never` in Practice: Exhaustive Checks, Impossible States, and Narrowing Dead Code A developer explains how TypeScript's `never` type can be used to enforce exhaustive checks in discriminated unions, making impossible states unrepresentable and forgotten branches a compile-time error. The pattern forces the compiler to prove exhaustiveness at every branch point, eliminating entire categories of bugs before runtime. never in Practice: Exhaustive Checks, Impossible States, and Narrowing Dead Code This article was written with the assistance of AI, under human supervision and review. 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. 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. never type represents values that cannot exist; the compiler assigns it to code paths proven unreachable through type narrowing. never in dead branches, enabling tree-shaking and exposing logic errors that runtime tests miss. never , void , and undefined prevents subtle bugs: never means "unreachable", void means "no return value", undefined means "optional value". 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 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. 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. 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. 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. 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 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: never represents values that cannot exist. A function returning never cannot return normally—it must throw an exception, enter an infinite loop, or terminate the process. void represents the absence of a return value. A function returning void completes normally but does not produce a value. It can still execute side effects. undefined is a value. A function returning undefined explicitly returns the value undefined . A parameter typed undefined can receive the value undefined .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. 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