{"slug": "typescript-never-in-practice-exhaustive-checks-impossible-states-and-narrowing", "title": "TypeScript `never` in Practice: Exhaustive Checks, Impossible States, and Narrowing Dead Code", "summary": "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.", "body_md": "`never`\n\nin Practice: Exhaustive Checks, Impossible States, and Narrowing Dead Code\n\nThis article was written with the assistance of AI, under human supervision and review.\n\nMost 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.\n\nThe `never`\n\ntype 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`\n\n, 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.\n\nThe 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.\n\n`never`\n\ntype represents values that cannot exist; the compiler assigns it to code paths proven unreachable through type narrowing.`never`\n\nin dead branches, enabling tree-shaking and exposing logic errors that runtime tests miss.`never`\n\n, `void`\n\n, and `undefined`\n\nprevents subtle bugs: `never`\n\nmeans \"unreachable\", `void`\n\nmeans \"no return value\", `undefined`\n\nmeans \"optional value\".`never`\n\nType and Why It Matters\nThe `never`\n\ntype 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`\n\n. This is not an error—it is proof that the code path is unreachable given the constraints.\n\nThe distinction matters because `never`\n\nis the only type that cannot be assigned to or from any other type except itself. A function returning `never`\n\ncannot complete normally; it must throw an exception or loop forever. A variable typed as `never`\n\ncan only arise from type narrowing that has excluded every possible value.\n\nConsider a union of string literals:\n\n```\ntype Status = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nfunction handleStatus(status: Status): void {\n  if (status === \"idle\") {\n    return;\n  }\n  if (status === \"loading\") {\n    return;\n  }\n  if (status === \"success\") {\n    return;\n  }\n  if (status === \"error\") {\n    return;\n  }\n  // At this point, status has type `never`\n  // because all possible values have been eliminated\n  const _exhaustiveCheck: never = status;\n}\n```\n\nThe variable `_exhaustiveCheck`\n\nexists 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.\n\nThe 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.\n\nIn other words, `never`\n\ntransforms 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.\n\nExhaustive 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`\n\nto make incomplete handling a type error.\n\nThe pattern works by creating a function that accepts only `never`\n\n. When control flow reaches this function, the compiler verifies that the input type has been narrowed to `never`\n\n—meaning all reachable cases have been handled. If a case remains, the type is not `never`\n\n, and the assignment fails.\n\n```\nfunction assertUnreachable(value: never): never {\n  throw new Error(`Unhandled case: ${JSON.stringify(value)}`);\n}\n\ntype ApiResponse = \n  | { type: \"success\"; data: unknown }\n  | { type: \"error\"; message: string }\n  | { type: \"loading\" };\n\nfunction processResponse(response: ApiResponse): void {\n  switch (response.type) {\n    case \"success\":\n      console.log(\"Data:\", response.data);\n      return;\n    case \"error\":\n      console.error(\"Error:\", response.message);\n      return;\n    case \"loading\":\n      console.log(\"Loading...\");\n      return;\n    default:\n      assertUnreachable(response);\n  }\n}\n```\n\nWhen a fourth response type arrives—say, `{ type: \"timeout\" }`\n\n—the compiler flags the `default`\n\nbranch immediately. The `response`\n\nvariable is no longer `never`\n\n; it is the unhandled `timeout`\n\nvariant. The error message points directly to the missing case.\n\nThis 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`\n\n, or the code does not compile.\n\nThe runtime behavior of `assertUnreachable`\n\nis irrelevant. The function should never execute in correctly typed code. Its purpose is compile-time enforcement. Some teams use `throw`\n\nto fail loudly if a type hole appears at runtime; others use `return`\n\nto satisfy the `never`\n\nsignature without introducing exceptions.\n\nThis 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.\n\n`never`\n\nState 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.\n\nThe pattern begins with a discriminated union where each state is a distinct type. The discriminant—commonly a `type`\n\nor `status`\n\nfield—allows the compiler to narrow the union in switch statements. Each state carries only the data valid for that state, making contradictory properties impossible.\n\n```\ntype ConnectionState =\n  | { status: \"disconnected\" }\n  | { status: \"connecting\"; startTime: number }\n  | { status: \"connected\"; socket: WebSocket; connectedAt: number }\n  | { status: \"reconnecting\"; attempt: number; lastError: Error };\n\ntype ConnectionEvent =\n  | { type: \"CONNECT\" }\n  | { type: \"CONNECTED\"; socket: WebSocket }\n  | { type: \"DISCONNECT\" }\n  | { type: \"ERROR\"; error: Error };\n\nfunction transition(\n  state: ConnectionState,\n  event: ConnectionEvent\n): ConnectionState {\n  switch (state.status) {\n    case \"disconnected\":\n      if (event.type === \"CONNECT\") {\n        return { status: \"connecting\", startTime: Date.now() };\n      }\n      return state;\n\n    case \"connecting\":\n      if (event.type === \"CONNECTED\") {\n        return { \n          status: \"connected\", \n          socket: event.socket, \n          connectedAt: Date.now() \n        };\n      }\n      if (event.type === \"ERROR\") {\n        return { status: \"reconnecting\", attempt: 1, lastError: event.error };\n      }\n      return state;\n\n    case \"connected\":\n      if (event.type === \"DISCONNECT\") {\n        state.socket.close();\n        return { status: \"disconnected\" };\n      }\n      if (event.type === \"ERROR\") {\n        state.socket.close();\n        return { status: \"reconnecting\", attempt: 1, lastError: event.error };\n      }\n      return state;\n\n    case \"reconnecting\":\n      if (event.type === \"CONNECTED\") {\n        return { \n          status: \"connected\", \n          socket: event.socket, \n          connectedAt: Date.now() \n        };\n      }\n      if (event.type === \"DISCONNECT\") {\n        return { status: \"disconnected\" };\n      }\n      return state;\n\n    default:\n      assertUnreachable(state);\n  }\n}\n```\n\nThis design makes several guarantees. First, the `socket`\n\nproperty exists if and only if the state is `connected`\n\n. Attempting to access `state.socket`\n\nin the `disconnected`\n\ncase 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\"`\n\n—breaks the build at every `transition`\n\ncall until handlers are added.\n\nThe 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.\n\nFor 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.\n\nThe 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`\n\nand `memberId: string`\n\nforces every consumer to validate which property wins. A request with both `method: \"GET\"`\n\nand a non-null `body`\n\ncrashes at runtime when the HTTP library rejects it.\n\nThe 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.\n\n```\n// Broken: allows contradictory states\ntype BrokenUser = {\n  isGuest: boolean;\n  memberId?: string;\n  email?: string;\n  guestToken?: string;\n};\n\n// Correct: invalid combinations are unrepresentable\ntype User =\n  | { type: \"guest\"; guestToken: string }\n  | { type: \"member\"; memberId: string; email: string };\n\nfunction displayUser(user: User): string {\n  switch (user.type) {\n    case \"guest\":\n      return `Guest (${user.guestToken})`;\n    case \"member\":\n      return `${user.email} (ID: ${user.memberId})`;\n    default:\n      assertUnreachable(user);\n  }\n}\n```\n\nThe 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`\n\nvalue is always exactly one variant, and each variant contains exactly the properties it needs.\n\nThis 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.\n\nReal-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).\n\nThe 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.\n\nControl 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`\n\n, proving the branch is unreachable.\n\nThe compiler uses this information to eliminate dead code during tree-shaking. If a branch's type is `never`\n\n, 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.\n\n```\ntype Shape =\n  | { kind: \"circle\"; radius: number }\n  | { kind: \"square\"; sideLength: number };\n\nfunction getArea(shape: Shape): number {\n  if (shape.kind === \"circle\") {\n    return Math.PI * shape.radius ** 2;\n  }\n\n  if (shape.kind === \"square\") {\n    return shape.sideLength ** 2;\n  }\n\n  // TypeScript knows shape is `never` here\n  // This branch is provably unreachable\n  const _exhaustive: never = shape;\n  return _exhaustive;\n}\n```\n\nWhen a developer adds a third shape—say, `{ kind: \"triangle\"; base: number; height: number }`\n\n—the function no longer compiles. The final branch receives `triangle`\n\ninstead of `never`\n\n, and the assignment fails. The error message points directly to the unhandled case.\n\nThis pattern catches logic errors that unit tests miss. If a developer writes:\n\n```\nif (shape.kind === \"circle\") {\n  return Math.PI * shape.radius ** 2;\n}\n\n// Accidentally forgot to check \"square\"\nreturn 0;\n```\n\nThe code compiles because the return type is `number`\n\n. 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`\n\ncould still be `square`\n\nat the return statement, so assigning it to `never`\n\nfails.\n\nThe 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.\n\nThis 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`\n\nunless that branch is genuinely unreachable.\n\n`never`\n\nvs `void`\n\nvs `undefined`\n\n: When to Use Each\nThe 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`\n\n, optional parameters that should accept `undefined`\n\nreject it, unreachable branches that should fail at compile time pass silently.\n\nThe distinctions:\n\n`never`\n\nrepresents values that cannot exist. A function returning `never`\n\ncannot return normally—it must throw an exception, enter an infinite loop, or terminate the process.`void`\n\nrepresents the absence of a return value. A function returning `void`\n\ncompletes normally but does not produce a value. It can still execute side effects.`undefined`\n\nis a value. A function returning `undefined`\n\nexplicitly returns the value `undefined`\n\n. A parameter typed `undefined`\n\ncan receive the value `undefined`\n\n.Practical usage:\n\n```\n// never: function cannot return normally\nfunction fail(message: string): never {\n  throw new Error(message);\n}\n\nfunction infiniteLoop(): never {\n  while (true) {\n    // process events forever\n  }\n}\n\n// void: function returns but produces no value\nfunction logMessage(message: string): void {\n  console.log(message);\n  // implicit return undefined, but type is void\n}\n\n// undefined: explicit value\nfunction findUser(id: string): User | undefined {\n  const user = database.get(id);\n  return user ?? undefined; // explicitly returning the value undefined\n}\n\n// Parameter types\nfunction handleEvent(\n  callback: () => void,        // callback can return anything, return value is ignored\n  cleanup?: () => undefined    // cleanup must explicitly return undefined if provided\n): void {\n  callback();\n  cleanup?.();\n}\n```\n\nThe confusion arises because `void`\n\nfunctions can include `return;`\n\nstatements or implicit `return undefined;`\n\nat the end. The type system treats these as equivalent—the function completes normally without a value. But the function's return type remains `void`\n\n, not `undefined`\n\n. The distinction matters for assignability: a function returning `void`\n\ncan be assigned to a function returning `undefined`\n\n, but not vice versa without type assertions.\n\nFor callbacks, `void`\n\nreturn types provide flexibility. A callback typed `() => void`\n\naccepts functions that return any type, discarding the return value. A callback typed `() => undefined`\n\nrequires functions that explicitly return `undefined`\n\n. Most callback signatures should use `void`\n\nunless they specifically need to enforce that the callback produces no return value.\n\nFor exhaustiveness checks, `never`\n\nis the only correct choice. The check exists to prove that a code path cannot execute. Using `void`\n\nor `undefined`\n\nsilently accepts unreachable branches, defeating the purpose.\n\nThis distinction is critical. When TypeScript narrows a type to `never`\n\n, it is proving unreachability through type analysis. Treating `never`\n\nas interchangeable with `void`\n\nor `undefined`\n\ndiscards that proof and reintroduces the bugs exhaustiveness checks exist to prevent.\n\nAPI 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.\n\nThe 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.\n\n```\ntype ApiResult<T> =\n  | { status: \"success\"; data: T; timestamp: number }\n  | { status: \"error\"; code: string; message: string }\n  | { status: \"unauthorized\"; redirectUrl: string }\n  | { status: \"rate_limited\"; retryAfter: number };\n\nasync function fetchUser(id: string): Promise<ApiResult<User>> {\n  const response = await fetch(`/api/users/${id}`);\n\n  if (response.status === 200) {\n    const data = await response.json();\n    return { status: \"success\", data, timestamp: Date.now() };\n  }\n\n  if (response.status === 401) {\n    const { redirectUrl } = await response.json();\n    return { status: \"unauthorized\", redirectUrl };\n  }\n\n  if (response.status === 429) {\n    const retryAfter = parseInt(response.headers.get(\"Retry-After\") ?? \"60\");\n    return { status: \"rate_limited\", retryAfter };\n  }\n\n  const { code, message } = await response.json();\n  return { status: \"error\", code, message };\n}\n\nfunction handleUserResult(result: ApiResult<User>): void {\n  switch (result.status) {\n    case \"success\":\n      displayUser(result.data);\n      updateCache(result.data, result.timestamp);\n      return;\n\n    case \"error\":\n      logError(result.code, result.message);\n      showErrorToast(result.message);\n      return;\n\n    case \"unauthorized\":\n      clearSession();\n      redirectTo(result.redirectUrl);\n      return;\n\n    case \"rate_limited\":\n      scheduleRetry(result.retryAfter);\n      showRateLimitNotice(result.retryAfter);\n      return;\n\n    default:\n      assertUnreachable(result);\n  }\n}\n```\n\nWhen the API introduces a `maintenance`\n\nstatus for scheduled downtime, the TypeScript compiler flags every call to `handleUserResult`\n\n. 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.\n\nThe 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:\n\n```\ntype Input = string | number | boolean | object;\n\nfunction processInput(input: Input): string {\n  if (typeof input === \"string\") {\n    return input.toUpperCase();\n  }\n\n  if (typeof input === \"number\") {\n    return input.toFixed(2);\n  }\n\n  if (typeof input === \"boolean\") {\n    return input ? \"yes\" : \"no\";\n  }\n\n  if (typeof input === \"object\") {\n    return JSON.stringify(input);\n  }\n\n  assertUnreachable(input);\n}\n```\n\nWhen the `Input`\n\nunion grows to include `bigint`\n\n, the function does not compile until a handler is added. The exhaustiveness check catches the gap immediately, before tests or code review.\n\nFor 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.\n\n`never`\n\nchecks with type assertions?\nType assertions like `as any`\n\nor `value as never`\n\ndisable 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.\n\n`never`\n\nfor optional function parameters?\nNo, `never`\n\nrepresents values that cannot exist; optional parameters represent values that might not be provided. Use `undefined`\n\nfor optional parameters: `function foo(x?: number)`\n\nor `function foo(x: number | undefined)`\n\n. A parameter typed `never`\n\ncannot be called with any argument.\n\nWhen 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.\n\nThe 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.\n\n`never`\n\nor `void`\n\nfor functions that throw exceptions?\nUse `never`\n\n. Functions returning `void`\n\ncan complete normally even if they include throw statements in some branches. Functions returning `never`\n\nmust never return normally—they either throw in all branches or loop forever. The distinction tells callers whether the function might return.\n\n`never`\n\nType Errors\nThe 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`\n\n(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.\n\nThe fix is always the same: add a handler for the unhandled case. If the error occurs in a `default`\n\nbranch, a case is missing from the switch statement. If it occurs in an `assertUnreachable`\n\ncall, a conditional branch has not been added for the new variant.\n\nThe second pitfall is overusing `never`\n\nwhere `void`\n\nor `undefined`\n\nis correct. Functions that complete normally should return `void`\n\n, not `never`\n\n. Parameters that accept the value `undefined`\n\nshould be typed `undefined`\n\n, not `never`\n\n. Using `never`\n\nin these contexts produces confusing type errors because the compiler treats `never`\n\nas the bottom type—no value can be assigned to it except through narrowing.\n\nThe 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:\n\n```\n// Broken: discriminant is `string`, not a union of literals\ntype BrokenMessage = {\n  type: string;\n  payload: unknown;\n};\n\n// Correct: discriminant is a union of literals\ntype Message =\n  | { type: \"connect\"; clientId: string }\n  | { type: \"disconnect\"; reason: string }\n  | { type: \"data\"; payload: unknown };\n```\n\nFor 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`\n\nbranch 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.\n\nWhen debugging unexpected `never`\n\nassignments, 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`\n\nwhere 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.\n\nThat covers the essential patterns for leveraging `never`\n\nin 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.", "url": "https://wpnews.pro/news/typescript-never-in-practice-exhaustive-checks-impossible-states-and-narrowing", "canonical_source": "https://dev.to/jsmanifest/typescript-never-in-practice-exhaustive-checks-impossible-states-and-narrowing-dead-code-1d5", "published_at": "2026-07-29 05:29:42+00:00", "updated_at": "2026-07-29 06:00:55.099389+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["TypeScript"], "alternates": {"html": "https://wpnews.pro/news/typescript-never-in-practice-exhaustive-checks-impossible-states-and-narrowing", "markdown": "https://wpnews.pro/news/typescript-never-in-practice-exhaustive-checks-impossible-states-and-narrowing.md", "text": "https://wpnews.pro/news/typescript-never-in-practice-exhaustive-checks-impossible-states-and-narrowing.txt", "jsonld": "https://wpnews.pro/news/typescript-never-in-practice-exhaustive-checks-impossible-states-and-narrowing.jsonld"}}