# TypeScript `never` in Practice: Exhaustive Checks, Impossible States, and Narrowing Dead Code

> Source: <https://dev.to/jsmanifest/typescript-never-in-practice-exhaustive-checks-impossible-states-and-narrowing-dead-code-1d5>
> Published: 2026-07-29 05:29:42+00:00

`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<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.
