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:
- When it’s known at the type level that a function never returns normally (it always throws an exception, or loops forever)
- 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.
never | void | |
|---|---|---|
| Meaning | No value of this type exists | Returns no meaningful value (the actual return value is undefined) |
| After the function call | The code following the call becomes unreachable | Execution continues normally after the call |
| Assignability | No value can be assigned to it | undefined can be assigned to it |
| Position in the type hierarchy | Bottom 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
switchstatements - 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
- Software Design Editorial Department, Software Design May 2024 Issue , Gijutsu-Hyoron (pp.102-123)
- TypeScript Deep Dive Japanese Version: never