Skip to main content

TS Ep 14: The Never Type & Exhaustive Checks

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 14: This Article
If any and unknown sit at the very top of TypeScript’s type hierarchy as universal types, never sits at the absolute bottom. Known in type theory as the Bottom Type ($\bot$), never represents a type set containing zero values—an empty set ($\emptyset$).

1. What is never? (The Bottom Type)
#

In Set Theory, a type is a set of possible values.

  • boolean is a set of 2 values ({ true, false }).
  • string is an infinite set of string values.
  • never is the Empty Set ($\emptyset$). There is literally no value in JavaScript that can ever have the type never.
                  Top Types: any | unknown (Universal Set)
                                  |
                                  v
              Primitives: string | number | boolean | object
                                  |
                                  v
                    Bottom Type: never (Empty Set ∅)

The Mathematical Rules of never
#

Because never is the empty set:

  1. never is a subtype of every other type (you can return never from a function expecting a string or number).
  2. No type can be assigned to never (except never itself). You cannot assign a string, number, any, or unknown value to a never variable.

2. The 3 Sources of never
#

TypeScript’s compiler produces never under 3 distinct circumstances:

Source 1: Unreachable Function Completion
#

Functions that never successfully reach their end point—either because they always throw an exception or enter an infinite loop—have a return type of never.

// Always throws an exception (never completes execution)
function raiseError(message: string): never {
  throw new Error(`[Fatal Failure]: ${message}`);
}

// Infinite processing loop (never reaches return statement)
function runWorkerEventLoop(): never {
  while (true) {
    // Process background queue forever
  }
}

Source 2: Impossible Type Intersections
#

Intersecting two mutually exclusive primitive types yields no overlapping values, resulting in never:

type ImpossibleStringNumber = string & number; // Evaluates to 'never'
type ImpossibleObject = { id: string } & { id: number }; // Property 'id' is 'never'

Source 3: Exhaustive Control Flow Narrowing
#

When Control Flow Analysis narrows a union type across if or switch branches until all possibilities are eliminated, the remaining type in the fallback branch becomes never.

function processPrimitive(val: string | number) {
  if (typeof val === "string") {
    // Inferred: string
  } else if (typeof val === "number") {
    // Inferred: number
  } else {
    // Inferred: never ! (All members of string | number have been handled)
    console.log(val); 
  }
}

3. Exhaustiveness Checking (Compile-Time Safety)
#

The most important practical application of never is Exhaustiveness Checking on Discriminated Unions.

Consider a payment processing state machine:

type PaymentState =
  | { status: "pending" }
  | { status: "processing"; txnId: string }
  | { status: "completed"; receiptUrl: string }
  | { status: "failed"; reason: string };

When building a function to handle PaymentState, we can assign the fall-through default state to a variable explicitly typed as never:

function handlePayment(state: PaymentState): string {
  switch (state.status) {
    case "pending":
      return "Payment is pending...";
    case "processing":
      return `Processing transaction ${state.txnId}`;
    case "completed":
      return `Payment successful! Receipt: ${state.receiptUrl}`;
    case "failed":
      return `Payment failed: ${state.reason}`;
    default:
      // 🟢 EXHAUSTIVE CHECK:
      // Because all 4 status states above are handled, 'state' is narrowed to 'never' here!
      const _exhaustiveCheck: never = state;
      return _exhaustiveCheck;
  }
}

4. Catching Future Refactoring Bugs Automatically
#

Now, imagine 6 months later, another engineer updates the PaymentState union to add a 5th state for refunds:

type PaymentState =
  | { status: "pending" }
  | { status: "processing"; txnId: string }
  | { status: "completed"; receiptUrl: string }
  | { status: "failed"; reason: string }
  | { status: "refunded"; refundId: string }; // NEW STATE ADDED!

The moment the engineer builds the code, TypeScript instantly throws a compiler error inside handlePayment() at the default: line:

Type '{ status: "refunded"; refundId: string; }' is not assignable to type 'never'.

Why did the compiler throw an error?
#

Because the "refunded" state was not handled by any case statement above, it fell through to default:. Inside default:, state was inferred as { status: "refunded"; refundId: string }.

Because you cannot assign { status: "refunded" } to a variable of type never, compilation fails immediately, alerting the engineer to update handlePayment() before shipping to production!


5. Production Utility: UnreachableCaseError
#

Rather than writing const _exhaustiveCheck: never = state; repeatedly, professional TypeScript codebases encapsulate this into a custom Error class:

export class UnreachableCaseError extends Error {
  constructor(val: never) {
    super(`Unreachable case executed for value: ${JSON.stringify(val)}`);
  }
}

function processPaymentWithClass(state: PaymentState): string {
  switch (state.status) {
    case "pending":
      return "Pending...";
    case "processing":
      return `Processing ${state.txnId}`;
    case "completed":
      return `Done: ${state.receiptUrl}`;
    case "failed":
      return `Failed: ${state.reason}`;
    case "refunded":
      return `Refunded: ${state.refundId}`;
    default:
      // Provides compile-time checking AND runtime exception safety!
      throw new UnreachableCaseError(state);
  }
}

Summary & Next Steps
#

In this episode:

  • We analyzed never as the Bottom Type ($\bot$) and empty set ($\emptyset$) in set theory.
  • We investigated the 3 sources of never: Unreachable functions, impossible intersections, and fully narrowed control flows.
  • We implemented Exhaustiveness Checking to guarantee that expanding Discriminated Unions forces updates across all handler logic at compile time.
  • We created a production UnreachableCaseError class.

In Episode 15: Immutability with as const, we will explore how const assertions lock down types into immutable literals and read-only tuples!

typescript - This article is part of a series.
Part 14: This Article