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" | "" | "success" | "error";
function handleStatus(status: Status): void {
if (status === "idle") {
return;
}
if (status === "") {
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: "" };
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 "":
console.log("...");
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 ( 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<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.
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.
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.
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.
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.
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.
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.
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.