What Is TypeScript's never Type? Implementation Patterns for Exhaustive Checking in switch Statements

How TypeScript's never type works and how it differs from void, plus how to implement exhaustiveness checking (exhaustive check) in switch statements using an assertNever function and the satisfies operator. Includes practical patterns for Redux reducers and state machines, and an FAQ.

When you handle a union type with a switch statement in TypeScript, whether every case is covered without gaps is exactly the kind of thing that tends to go unnoticed until runtime. This article first lays out where TypeScript’s never type sits within the type system, then walks through two implementation patterns for exhaustiveness checking in switch statements: the assertNever function and the satisfies operator. It closes with practical application patterns for Redux reducers and state machines, along with answers to some common questions.

1. What Is the never Type? Its Place in the Type System

TypeScript’s types can be loosely ranked by “how many values they permit.” At one end sits unknown (the top type, which permits any value), and at the other sits never (the bottom type, which permits no value at all). What the never type represents is the state of “no value of this type exists in the world,” and it is treated as a subtype of every other type. Put differently, there is no value that can ever be assigned to a variable of type never.

never shows up, broadly speaking, in two situations:

  1. When it’s known at the type level that a function never returns normally (it always throws an exception, or loops forever)
  2. When repeated type narrowing has reached a point where, in theory, no remaining type applies

The Difference from void

never is frequently confused with void. The two are similar in that “the return value carries no meaning,” but their meaning within the type system is completely different.

nevervoid
MeaningNo value of this type existsReturns no meaningful value (the actual return value is undefined)
After the function callThe code following the call becomes unreachableExecution continues normally after the call
AssignabilityNo value can be assigned to itundefined can be assigned to it
Position in the type hierarchyBottom type (subtype of every type)An ordinary type like any other
// void: "returns no meaningful value, but the function returns normally"
function log(message: string): void {
  console.log(message);
  // Processing ends here and control returns to the caller
}

// never: "this function never returns normally"
function fail(message: string): never {
  throw new Error(message);
  // Because it throws, the function never finishes via a return
}

log("done"); // Execution continues to the next line after this
fail("error"); // An exception is thrown here, and code after this is never reached
console.log("unreachable");

log returns void, but processing continues normally after it’s called. fail, on the other hand, returns never, and TypeScript recognizes that this function “never returns normally.” This property plays an important role both in the exhaustiveness checking discussed later and in type narrowing after conditional branches.

2. Where never Shows Up

Unreachable Code

The code immediately following a call to a function that returns never is judged “unreachable” by TypeScript’s control flow analysis. You can use this to narrow types after a guard clause.

function fail(message: string): never {
  throw new Error(message);
}

function toUpperCase(input: string | null): string {
  if (input === null) {
    fail("input must not be null");
    // Because fail() returns never, TypeScript knows
    // that execution never proceeds past this point
  }
  // In this scope, input has been narrowed to type string
  return input.toUpperCase();
}

Because fail’s return type is never, TypeScript can exclude null from input after the if block. If fail’s return type had been void instead, TypeScript would assume that “execution might continue after fail,” and input would remain string | null.

The End of Narrowing

When you’ve exhausted all the type narrowing possible via if or typeof checks, whatever type theoretically remains for the “none of the above” case becomes never.

function describe(value: string | number): string {
  if (typeof value === "string") {
    return `string: ${value}`;
  } else if (typeof value === "number") {
    return `number: ${value}`;
  } else {
    // Nothing remains once string and number are removed from string | number
    // value's type becomes never here
    const exhausted: never = value;
    throw new Error(`Unexpected value: ${exhausted}`);
  }
}

value has type string | number, and the two typeof branches handle both cases completely. So there’s theoretically no value of value that can reach the else clause, and TypeScript infers it as never. The exhaustiveness checking covered in the next section applies exactly this property to switch statements.

3. Implementing Exhaustiveness Checking in switch Statements

The Basic Pattern: An assertNever Function

When a switch statement handles a union type, a standard technique for guaranteeing at compile time that every case is covered is to assign a value to the never type in the default clause. Rather than doing a bare assignment, wrapping this in a dedicated function makes it easier to reuse.

function assertNever(x: never): never {
  throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number }
  | { kind: "rectangle"; width: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.size ** 2;
    case "rectangle":
      return shape.width * shape.height;
    default:
      return assertNever(shape);
  }
}

Because the case clauses handle "circle", "square", and "rectangle" completely, shape’s type has already been narrowed to never by the time execution reaches the default clause. Since assertNever’s parameter requires type never, this passes type checking correctly.

Compared to a bare assignment like const _: never = shape;, wrapping this in an assertNever function has two advantages:

  • The same check can be reused across multiple switch statements
  • If an unexpected value somehow shows up at runtime (say, an API response’s actual shape diverges from its declared type), it throws an exception with a helpful error message

In other words, assertNever is a pattern that bundles a compile-time check and a runtime fail-safe into a single function.

A More Modern Pattern: Exhaustiveness Checking for Mappings via satisfies

The satisfies operator, introduced in TypeScript 4.9, lets you guarantee exhaustiveness from a different angle than switch statements. For a simple value mapping, combining an object literal with satisfies Record<...> can be more concise.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number }
  | { kind: "rectangle"; width: number; height: number };

// An exhaustive map of a display label per kind
const shapeLabels = {
  circle: "Circle",
  square: "Square",
  rectangle: "Rectangle",
} satisfies Record<Shape["kind"], string>;

function label(shape: Shape): string {
  return shapeLabels[shape.kind];
}

Writing satisfies Record<Shape["kind"], string> tells TypeScript the constraint that “shapeLabels must be an object that has every key Shape["kind"] can take.” If even one key is missing, it’s a straight-up compile error.

The assertNever pattern suits logic where the branches themselves are complex, while a satisfies-based map reads more cleanly when you just want to express a simple key-to-value correspondence. The two aren’t mutually exclusive — pick whichever fits the situation.

4. A Concrete Example: Catching a Missing Case as a Compile Error After Extending a Union

The best way to feel the effect of exhaustiveness checking is to actually add a member to a union type. Let’s add "triangle" to the Shape type from before.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number }; // added

If you leave the area function as-is and forget to add a case "triangle", the type of shape reaching the default clause is no longer never — it becomes { kind: "triangle"; base: number; height: number }. That produces a compile error like this:

Argument of type '{ kind: "triangle"; base: number; height: number; }'
is not assignable to parameter of type 'never'.

The satisfies-based shapeLabels produces a similar error:

Property 'triangle' is missing in type
'{ circle: string; square: string; rectangle: string; }'
but required in type 'Record<"circle" | "square" | "rectangle" | "triangle", string>'.

Both errors let you catch a missing case implementation or a missing map key immediately — via tsc (or right in your editor) — without waiting for a test run. Extending a union type with a new member is a refactor that happens constantly in real codebases, so this “catch it as a compile error” property directly cuts down on review overhead.

5. Practical Application Patterns

Redux Actions

In Redux and similar state-management approaches, it’s typical to define action types as a union and dispatch handling inside a reducer’s switch statement. Wiring in assertNever here lets you catch a forgotten action case at compile time.

type CounterAction =
  | { type: "counter/increment" }
  | { type: "counter/decrement" }
  | { type: "counter/reset"; payload: number };

function counterReducer(state: number, action: CounterAction): number {
  switch (action.type) {
    case "counter/increment":
      return state + 1;
    case "counter/decrement":
      return state - 1;
    case "counter/reset":
      return action.payload;
    default:
      return assertNever(action);
  }
}

If a new action (say, "counter/incrementBy") is added to CounterAction but its case is never implemented, the assertNever(action) line becomes a compile error. The bigger a Redux-based application’s reducers grow, the more this mechanism pays off.

State Machines

The same technique works just as well when you represent the state of an asynchronous operation as a union like "idle" | "loading" | "success" | "error" and handle the per-state display or transition logic in a switch statement.

type FetchStatus =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string }
  | { status: "error"; error: string };

function renderMessage(state: FetchStatus): string {
  switch (state.status) {
    case "idle":
      return "Idle";
    case "loading":
      return "Loading...";
    case "success":
      return `Success: ${state.data}`;
    case "error":
      return `Error: ${state.error}`;
    default:
      return assertNever(state);
  }
}

A state machine needs every bit of its display and transition logic updated each time a state is added, which makes forgotten updates an easy way to introduce bugs. Placing assertNever in the default clause prevents the classic mistake of adding a new state without writing the corresponding branch — catching it at compile time instead.

6. Summary

The never type is the bottom type representing “no value exists,” and it shows up in unreachable code and at the end of exhaustive type narrowing. Applying this property to a switch statement’s default clause lets you verify, at compile time, whether every case of a union type has been handled. The assertNever-function pattern suits complex branching logic, while a satisfies-based map suits simple value correspondences. For code where a union type is likely to keep growing over time — Redux reducers, state machines, and the like — it’s worth deliberately building in this kind of exhaustiveness checking.

Frequently Asked Questions (FAQ)

Q. What’s the difference between never and void?

void represents “returns no meaningful value,” and the function returns to its caller normally (its actual return value is undefined). never, on the other hand, is the bottom type representing “no value of this type exists” — a function that returns never never returns normally at all (it always throws, or loops forever). After calling a function that returns void, execution continues as usual; after calling one that returns never, the following code is treated as unreachable.

Q. Should you always write a default clause?

For exhaustiveness checking to work, you need a default clause that assigns to the never type (or calls assertNever) inside it. If you omit the default clause, TypeScript’s behavior becomes “do nothing if an unhandled value shows up” — a missing case won’t produce a compile error. For any switch statement where you want exhaustiveness guaranteed, it’s recommended to always pair a default clause with a never check.

Q. Should you use enum or a union type?

From the narrow standpoint of exhaustiveness checking alone, the assertNever pattern shown in this article works identically whether you use a string-literal union or an enum. If a switch statement handles every member of an enum completely, the type in the default clause is narrowed down to never in exactly the same way. So the choice shouldn’t hinge on whether exhaustiveness checking is possible — it should be based on other considerations, like bundle size, tree-shaking, compatibility with serialization formats used by other systems, and whatever conventions your codebase already follows.

References