If you've been writing TypeScript for a while, you've probably seen never in error messages or type definitions and wondered what it actually does. It's not just an obscure edge case — never is one of the most practical tools in the type system once you understand how to use it.
Let's walk through three patterns that'll make your TypeScript safer and your codebase cleaner.
Pattern 1: The Switch Statement That Never Lies
You've been here before. You have a union type representing different states:
type ApiState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; message: string };
And you write a render function that handles each case:
function renderState(state: ApiState) {
switch (state.status) {
case "idle":
return "Waiting...";
case "loading":
return "Loading...";
case "success":
return `Data: ${state.data}`;
case "error":
return `Error: ${state.message}`;
}
}
This works fine — until someone adds a new variant:
type ApiState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; message: string }
| { status: "rate_limited"; retryAfter: number }; // New!
Now renderState silently returns undefined for the new rate_limited case. No compile error. No warning. Just a runtime bug waiting to happen.
The fix: Add an exhaustiveness check using never:
function renderState(state: ApiState) {
switch (state.status) {
case "idle":
return "Waiting...";
case "loading":
return "Loading...";
case "success":
return `Data: ${state.data}`;
case "error":
return `Error: ${state.message}`;
default:
// If a new variant is added, this line won't compile
const _exhaustive: never = state;
return _exhaustive;
}
}
Now when rate_limited is added, TypeScript throws:
Type '{ status: "rate_limited"; retryAfter: number; }' is not assignable to type 'never'.
You add the missing case, the error goes away, and you ship confidently. This pattern alone is worth the price of admission — I've caught dozens of missed cases this way across production codebases.
This pattern works with discriminated unions, string literal unions, and any type where you handle all known variants. Use it everywhere you have a switch/match/then-else chain that must stay in sync with a union.
Pattern 2: Filtering Union Types with never and Conditional Types
never is the identity element of unions in TypeScript — T | never evaluates to T, and never gets automatically stripped from union types. This property is what makes TypeScript's built-in utility types work.
Here's how Exclude works under the hood:
type MyExclude<T, U> = T extends U ? never : T;
When you write Exclude<string | number | boolean, boolean>, TypeScript distributes the conditional over each member:
-
string extends boolean ? never : string→string -
number extends boolean ? never : number→number -
boolean extends boolean ? never : boolean→never
The final result: string | number. The never gets stripped automatically.
You can use this pattern yourself. Say you have an event system and want to filter out certain event types:
type AppEvent =
| { kind: "click"; x: number; y: number }
| { kind: "keypress"; key: string }
| { kind: "focus"; element: string }
| { kind: "blur"; element: string }
| { kind: "resize"; width: number; height: number };
// Filter only events with x/y coordinates
type PointerEvents = {
[K in AppEvent["kind"]]: K extends "click" ? Extract<AppEvent, { kind: K }> : never;
}[AppEvent["kind"]];
// Result: { kind: "click"; x: number; y: number }
Or more practically, create a helper that removes never-returning functions from a function union:
type Returnable<T> = T extends (...args: any[]) => never ? never : T;
type MyFunctions = (() => string) | (() => never) | ((x: number) => void);
// Result: (() => string) | ((x: number) => void)
Pattern 3: Type-Level Validation with never in Mapped Types
You can embed never into mapped types to transform or validate object shapes at compile time.
Here's a pattern I use regularly — marking deprecated properties so accessing them is a compile error:
interface OldConfig {
apiUrl: string;
timeout: number;
/** @deprecated Use apiUrl instead */
endpoint: never;
}
// Or more dynamically, create a type that blocks certain keys:
type BlockedKeys = "password" | "secret" | "token";
type SafeConfig<T> = {
[K in keyof T]: K extends BlockedKeys ? never : T[K];
};
interface RawConfig {
apiUrl: string;
password: string;
timeout: number;
}
// Using SafeConfig makes password unusable
type Safe = SafeConfig<RawConfig>;
// { apiUrl: string; password: never; timeout: number }
Another practical use — ensuring a function parameter is narrowed at compile time:
function assertUnreachable(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
This function is your safety net. Call it anywhere TypeScript should prove a code path is impossible. If you ever reach that path at runtime, it explodes loudly instead of silently corrupting state.
Understanding Never vs Void vs Unknown
A common point of confusion is the difference between these three:
| Type | What it means | What you can assign to it |
|---|---|---|
void |
Function returns no useful value (returns undefined) |
undefined and void
|
never |
Function never returns (throws or infinite loop) | Nothing (bottom type) |
unknown |
Could be anything (top type) | Everything |
any |
Opts out of type checking entirely | Everything (dangerous) |
function throwError(): never {
throw new Error("Always throws");
}
function logMessage(): void {
console.log("Returns undefined");
}
let n: never = undefined; // ❌ Error: Type 'undefined' is not assignable to type 'never'
let v: void = undefined; // ✅ OK
let u: unknown = 42; // ✅ OK
A function declared as returning never tells TypeScript (and other developers) that this function will never complete normally. This enables control-flow narrowing:
function fail(message: string): never {
throw new Error(message);
}
function processValue(value: string | null): string {
if (value === null) {
return fail("Value was null"); // TypeScript knows this throws, not returns
}
return value.toUpperCase(); // TypeScript knows value is string here
}
Putting It All Together
Here's a real-world example combining all three patterns — a Redux-style reducer with exhaustive type checking and runtime safety:
type Action =
| { type: "increment"; amount: number }
| { type: "decrement"; amount: number }
| { type: "reset" }
| { type: "set"; value: number };
type State = { count: number };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { count: state.count + action.amount };
case "decrement":
return { count: state.count - action.amount };
case "reset":
return { count: 0 };
case "set":
return { count: action.value };
default:
return assertUnreachable(action);
}
}
function assertUnreachable(x: never): never {
throw new Error(`Unexpected action: ${x}`);
}
If you add a new action type to the union but forget to handle it in the reducer, TypeScript tells you immediately. The default branch catches it at compile time, and assertUnreachable catches it at runtime if someone bypasses the type system with a cast.
The Bottom Line
The never type isn't an academic curiosity. It's a sandbag in your type system's flood wall:
- Exhaustive checks keep switch statements honest when your union grows
- Conditional type filtering lets you build precise utility types that strip away unwanted variants
- Mapped type validation blocks deprecated properties and enforces compile-time constraints
Start with the exhaustive check pattern — add a default case with a never assignment to every switch on a union type. It takes five seconds and pays for itself the first time someone adds a variant to a union you wrote three months ago.
Top comments (0)