TypeScript 17 ๐ท Exhaustiveness Checking and never
The never type is the empty type โ a type with no values. Nothing is assignable to it, and it’s assignable to everything. That sounds useless until you realize what it enables: exhaustiveness checking. When you handle every branch of a union, TypeScript narrows the remaining type to never โ and if you didn’t handle every branch, the remaining type is whatever you missed. By assigning that remainder to a never variable, you force the compiler to prove you handled everything. It’s the compiler as a checklist.
Key point: never means “no possible value.” After a switch covers all branches of a union, the remaining type is never. If a branch is missing, the remaining type is the missing branch โ not never โ and assigning it to a never variable fails. That failure is the exhaustiveness check. assertNever wraps this into a one-line helper.
What never is
never is the bottom type โ the subtype of every type. It has no instances. No value is a never.
let x: never;
x = 1; // โ
x = 'a'; // โ
x = null; // โ
x = undefined; // โ
// Nothing can be assigned to `never`.
Two things make never useful:
- Assignable to everything โ
nevercan be assigned to any type - Nothing assignable to it โ no value qualifies as
never
Where never appears:
- Function return types โ a function that never returns
- Exhaustiveness checks โ the leftover after all cases
- Impossible type combinations โ
string & numberisnever - After infinite loops โ the code below is unreachable
function fail(message: string): never {
throw new Error(message);
}
fail never returns โ it always throws. Its return type is never.
Why an empty type exists: Type theory needs a bottom โ a type that’s a subtype of every other. It represents “impossible” and “unreachable.” In TypeScript, that bottom is
never, and it’s the foundation for exhaustiveness checking, unreachable-code analysis, and error types likeResult.
never in function returns
A function returning never never completes โ it throws or loops forever.
function fail(msg: string): never {
throw new Error(msg);
}
function infinite(): never {
while (true) {}
}
Why this matters: A function that returns never is compatible with every signature. A callback that must return string can be given a never-returning function โ the compiler knows it never returns, so no return value is needed.
const parse: (s: string) => number = fail; // โ
โ never is assignable to number
In practice, never return types mark functions that can’t complete normally.
Unreachable code analysis:
function f(x: string | number): string {
if (typeof x === 'string') return x;
if (typeof x === 'number') return `${x}`;
// After both branches, x is `never` โ nothing left
const _exhaustive: never = x;
return _exhaustive;
}
After handling both branches, x is never. That’s the compiler’s proof that every case is covered.
Why
neverreturn types matter: A function that throws is different from one that returns. When TypeScript knows a function never returns, it can eliminate code that follows a call, understand unreachable branches, and accept the function where a stricter signature is expected. It’s a small, precise annotation that makes control flow analysis sharper.
Exhaustiveness โ the core idea
Take a discriminated union, switch on the discriminant, and check that the remaining type is never.
type Status = 'idle' | 'loading' | 'ready' | 'error';
function message(s: Status): string {
switch (s) {
case 'idle': return 'Waiting';
case 'loading': return 'Loading...';
case 'ready': return 'Ready';
case 'error': return 'Failed';
}
}
After all four cases, TypeScript knows s can’t be anything else. The function always returns โ every branch is covered. This compiles cleanly.
Now remove one case:
function message(s: Status): string {
switch (s) {
case 'idle': return 'Waiting';
case 'loading': return 'Loading...';
case 'ready': return 'Ready';
// โ 'error' not handled
}
// TypeScript: not all code paths return a value
}
TypeScript catches the missing case. Without a default or a final return, the function’s return type is violated.
The default case makes the check explicit:
function assertNever(x: never): never {
throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}
function message(s: Status): string {
switch (s) {
case 'idle': return 'Waiting';
case 'loading': return 'Loading...';
case 'ready': return 'Ready';
case 'error': return 'Failed';
default: return assertNever(s);
}
}
In the default branch, s is never โ every case is handled. If you add a new status and forget to update the switch, s isn’t never in default, and assertNever(s) fails to compile. That failure is the check.
Why the default is better than relying on the return-type check: It gives a precise error message and catches missing cases even when the function doesn’t need to return a value.
Why exhaustiveness matters: Every union has a fixed set of branches. When you add one, every consumer must handle it. Without exhaustiveness checking, missed cases become silent bugs โ a missing handler, a broken state. With it, the compiler forces you to update every switch. That’s the difference between hoping you updated everything and knowing it.
The assertNever helper
assertNever is a one-line function that turns exhaustiveness into a compile-time check.
function assertNever(x: never): never {
throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}
What it does:
- Accepts only a
never - If the argument isn’t
never, the call fails to compile - If the argument is
never, the function throws at runtime โ but it should never be reached
How to use it:
type Event =
| { kind: 'click'; x: number; y: number }
| { kind: 'keydown'; key: string }
| { kind: 'scroll'; offset: number };
function handle(e: Event): void {
switch (e.kind) {
case 'click': use(e.x, e.y); break;
case 'keydown': use(e.key); break;
case 'scroll': use(e.offset); break;
default: return assertNever(e);
}
}
Adding a new kind:
type Event =
| { kind: 'click'; x: number; y: number }
| { kind: 'keydown'; key: string }
| { kind: 'scroll'; offset: number }
| { kind: 'hover'; target: string };
Now handle fails to compile โ e in the default branch is { kind: 'hover'; target: string }, not never. You must add a case for hover.
Variants of assertNever:
// Minimal
function assertNever(x: never): never {
throw new Error(`Unexpected: ${x}`);
}
// With JSON for better debug
function assertNever(x: never): never {
throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}
// Inline (no helper)
default: {
const _: never = e;
throw new Error('Unreachable');
}
The inline version does the same thing: assigns e to a never variable. If e isn’t never, the assignment fails.
Why a named helper: Reusable, self-documenting, and gives a clear runtime error if somehow reached. The name assertNever reads as “this should be impossible.”
Why
assertNeveris idiomatic: It combines a compile-time check (the argument must benever) with a runtime safeguard (it throws if reached). If the union is fully handled, the branch is unreachable โ the throw never fires. If somehow the code reaches it due to a bug or a missing case that slipped through, it throws with a useful message. Best of both worlds.
Exhaustiveness patterns
Several patterns enforce exhaustiveness. Pick the one that fits.
Pattern 1 โ assertNever in default:
switch (value.kind) {
case 'a': return handleA(value);
case 'b': return handleB(value);
default: return assertNever(value);
}
Most common. Precise errors.
Pattern 2 โ inline never assignment:
switch (value.kind) {
case 'a': return handleA(value);
case 'b': return handleB(value);
default: {
const _exhaustive: never = value;
throw new Error(`Unhandled: ${JSON.stringify(value)}`);
}
}
No helper needed. The _exhaustive variable is never used โ it exists only for the type check.
Pattern 3 โ exhaustive if chain:
function handle(x: 'a' | 'b' | 'c'): string {
if (x === 'a') return 'A';
if (x === 'b') return 'B';
if (x === 'c') return 'C';
return assertNever(x); // x is never here
}
Works when the conditions eliminate all branches. Less common than switch.
Pattern 4 โ function with never return:
function handle(e: Event): string {
switch (e.kind) {
case 'a': return 'a';
case 'b': return 'b';
}
// โ missing return for the default โ but if all cases return,
// TypeScript sees the function always returns and is happy.
}
If every branch returns and none fall through, no default is needed โ the function’s return type is satisfied.
Pattern 5 โ mapped type exhaustiveness:
type Handlers = {
[K in Event['kind']]: (e: Extract<Event, { kind: K }>) => void;
};
const handlers: Handlers = {
click: e => use(e.x, e.y),
keydown: e => use(e.key),
scroll: e => use(e.offset)
// โ missing key โ compile error
};
A mapped type over the discriminant forces an entry for each kind. This is the most robust pattern for dispatch tables.
Which pattern to use:
| Situation | Pattern |
|---|---|
| Switch over discriminated union | assertNever in default |
| No helper import | inline never assignment |
| Simple literal union | if chain + assertNever |
| Every branch returns | no default needed |
| Dispatch table | mapped type over discriminant |
Why multiple patterns: Different situations favor different shapes. A
switchwithassertNeveris standard. A dispatch table using a mapped type is more concise for handlers. Pick the one that reads clearest for the code you’re writing.
never and unreachable code
never also marks code that can’t be reached.
function f(x: 'a' | 'b'): string {
if (x === 'a') return 'A';
if (x === 'b') return 'B';
// x is never here โ this is unreachable
return x;
}
TypeScript narrows x to never after both branches. The final return x returns a never โ which is assignable to string but never actually executes.
Unreachable code detection:
function g(): void {
return;
console.log('never runs'); // โ unreachable
}
TypeScript flags unreachable code after return, throw, or an infinite loop.
never after throw:
function h(x: string): string {
if (!x) throw new Error('empty');
// x is string here โ `throw` returns never, so the branch doesn't continue
return x;
}
throw returns never, so the if branch terminates. The code after is reachable with x narrowed.
Infinite loop:
function loop(): never {
while (true) {}
console.log('never runs'); // โ
}
The loop never exits, so the code after is unreachable, and the function returns never.
Why unreachable-code detection matters: Dead code hides bugs and confusion. If TypeScript can prove a branch is unreachable, the compiler flags it. That’s especially useful after refactors โ code that used to be reachable but no longer is gets caught.
never in types
never isn’t only for function returns. It appears in type-level operations too.
Empty intersections:
type Impossible = string & number; // never
A value can’t be both a string and a number โ the intersection is empty.
never in unions:
type A = string | never; // string
never is the identity for union โ adding it changes nothing.
never in conditional types:
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
// For never:
type C = IsString<never>; // never (special case โ never distributes oddly)
never distributes through conditional types in a special way โ it produces never. This is a subtle behavior that catches people out.
Filtering with never:
type NonNullish<T> = T extends null | undefined ? never : T;
type A = NonNullish<string | null>; // string
type B = NonNullish<number | undefined>; // number
Removing a case from a union by mapping it to never.
never in mapped types:
type OptionalKeys<T> = {
[K in keyof T]: T[K] extends undefined ? never : K;
}[keyof T];
Extracting keys whose values include undefined.
Why never appears in types: It’s the identity for union, the absorbing element for intersection, and the “filter this out” marker in conditional types. It’s TypeScript’s way of saying “no value” at every level โ values, functions, and types.
Why
neverdistribution matters: Conditional types distribute over unions. When the input isneverโ the empty union โ the distribution producesnever. This is often a source of subtle bugs in advanced type code. Guard against it with[T] extends [never]when needed.
Exhaustiveness across functions
Exhaustiveness works inside a single function scope. Across function boundaries, you need predicates or a shared helper.
Inside one function:
function handle(e: Event): void {
switch (e.kind) {
case 'a': break;
case 'b': break;
default: assertNever(e);
}
}
Narrowing is tracked in the same scope.
Across functions with a shared handler map:
const handlers: {
[K in Event['kind']]: (e: Extract<Event, { kind: K }>) => void;
} = {
click: e => use(e.x, e.y),
keydown: e => use(e.key),
scroll: e => use(e.offset)
};
A mapped type forces every key. Missing one is a compile error at the map’s declaration, not at the call site.
Inside a callback:
events.forEach(e => {
switch (e.kind) {
case 'a': break;
case 'b': break;
default: assertNever(e);
}
});
Narrowing works inside the callback because it’s the same switch statement.
In a reducer:
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'ADD': return add(state, action.item);
case 'REMOVE': return remove(state, action.id);
case 'CLEAR': return initial;
default: return assertNever(action);
}
}
Reducers are the classic example โ every action type must be handled.
Why function boundaries matter: Narrowing is scope-local. Inside a callback or a separate function, the compiler can’t carry over what it proved elsewhere. Exhaustiveness via
assertNeverworks because the switch and the check are in the same scope. For shared dispatch, use a mapped-type handler map to enforce completeness at the declaration.
A full example
A state machine with exhaustive transitions.
// ============================================
// TYPES
// ============================================
type State =
| { status: 'idle' }
| { status: 'loading'; startedAt: Date }
| { status: 'success'; data: string }
| { status: 'error'; message: string; retryCount: number };
type Event =
| { kind: 'START' }
| { kind: 'RESOLVE'; data: string }
| { kind: 'REJECT'; message: string }
| { kind: 'RESET' };
// ============================================
// ASSERT NEVER
// ============================================
function assertNever(x: never): never {
throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}
// ============================================
// TRANSITION
// ============================================
function transition(state: State, event: Event): State {
switch (state.status) {
case 'idle':
if (event.kind === 'START') {
return { status: 'loading', startedAt: new Date() };
}
return state;
case 'loading':
if (event.kind === 'RESOLVE') {
return { status: 'success', data: event.data };
}
if (event.kind === 'REJECT') {
return { status: 'error', message: event.message, retryCount: 0 };
}
return state;
case 'success':
if (event.kind === 'RESET') return { status: 'idle' };
return state;
case 'error':
if (event.kind === 'RESET') return { status: 'idle' };
if (event.kind === 'START') {
return { status: 'loading', startedAt: new Date() };
}
return state;
default:
return assertNever(state);
}
}
// ============================================
// DESCRIBE
// ============================================
function describe(state: State): string {
switch (state.status) {
case 'idle': return 'Ready';
case 'loading': return `Loading since ${state.startedAt.toISOString()}`;
case 'success': return `Loaded: ${state.data}`;
case 'error': return `Error: ${state.message} (retries: ${state.retryCount})`;
default: return assertNever(state);
}
}
// ============================================
// USAGE
// ============================================
let state: State = { status: 'idle' };
console.log(describe(state));
state = transition(state, { kind: 'START' });
console.log(describe(state));
state = transition(state, { kind: 'RESOLVE', data: 'hello' });
console.log(describe(state));
state = transition(state, { kind: 'RESET' });
console.log(describe(state));
Every transition and description is exhaustively handled. Adding a new state to the union triggers compile errors in both functions.
What this shows:
- Two exhaustive functions over the same union
- Nested narrowing โ status first, then event kind
assertNeverindefaultbranches- TypeScript catches missing cases
Why this pattern matters: State machines are everywhere โ network requests, form wizards, game logic, UI modes. Discriminated unions model them, and exhaustiveness ensures every state is handled. Adding a new state is a compile-time event: the compiler lists every place that needs updating.
Complete Example Session
# ============================================
# PART 1: BASIC EXHAUSTIVENESS
# ============================================
cat > exhaustive.ts << 'EOF'
type Status = 'idle' | 'loading' | 'ready' | 'error';
function assertNever(x: never): never {
throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}
function message(s: Status): string {
switch (s) {
case 'idle': return 'Waiting';
case 'loading': return 'Loading...';
case 'ready': return 'Ready';
case 'error': return 'Failed';
default: return assertNever(s);
}
}
console.log(message('idle'));
console.log(message('ready'));
EOF
npx tsc --noEmit exhaustive.ts
# (no errors)
# ============================================
# PART 2: TRIGGER EXHAUSTIVENESS ERROR
# ============================================
cat > missing.ts << 'EOF'
type Status = 'idle' | 'loading' | 'ready' | 'error';
function assertNever(x: never): never { throw x; }
function message(s: Status): string {
switch (s) {
case 'idle': return 'Waiting';
case 'loading': return 'Loading...';
case 'ready': return 'Ready';
// โ 'error' not handled
default: return assertNever(s);
}
}
EOF
npx tsc --noEmit missing.ts
# [ missing.ts:11:32 - Argument of type '"error"' is not assignable to parameter of type 'never'. ]
rm missing.ts
# ============================================
# PART 3: DISCRIMINATED UNION EXHAUSTIVENESS
# ============================================
cat > du.ts << 'EOF'
type Event =
| { kind: 'click'; x: number; y: number }
| { kind: 'keydown'; key: string }
| { kind: 'scroll'; offset: number };
function assertNever(x: never): never { throw new Error(JSON.stringify(x)); }
function handle(e: Event): string {
switch (e.kind) {
case 'click': return `${e.x},${e.y}`;
case 'keydown': return e.key;
case 'scroll': return `${e.offset}`;
default: return assertNever(e);
}
}
console.log(handle({ kind: 'click', x: 1, y: 2 }));
EOF
npx tsc --noEmit du.ts
# (no errors)
# ============================================
# PART 4: NEVER IN RETURN TYPES
# ============================================
cat > never-return.ts << 'EOF'
function fail(msg: string): never {
throw new Error(msg);
}
function infinite(): never {
while (true) {}
}
// never is assignable to any type
const f: (s: string) => number = fail;
function handle(x: string | number): string {
if (typeof x === 'string') return x;
if (typeof x === 'number') return `${x}`;
const _exhaustive: never = x;
return _exhaustive;
}
console.log(handle('hi'), handle(42));
EOF
npx tsc --noEmit never-return.ts
# (no errors)
# ============================================
# PART 5: STATE MACHINE
# ============================================
cat > machine.ts << 'EOF'
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: string }
| { status: 'error'; message: string };
function assertNever(x: never): never { throw x; }
function describe(s: State): string {
switch (s.status) {
case 'idle': return 'Ready';
case 'loading': return 'Loading...';
case 'success': return `Data: ${s.data}`;
case 'error': return `Error: ${s.message}`;
default: return assertNever(s);
}
}
const states: State[] = [
{ status: 'idle' },
{ status: 'loading' },
{ status: 'success', data: 'hello' },
{ status: 'error', message: 'oops' }
];
for (const s of states) console.log(describe(s));
EOF
npx tsc --noEmit machine.ts
# (no errors)
# ============================================
# PART 6: MAPPED TYPE EXHAUSTIVENESS
# ============================================
cat > mapped.ts << 'EOF'
type Event =
| { kind: 'click'; x: number; y: number }
| { kind: 'keydown'; key: string };
type Handlers = {
[K in Event['kind']]: (e: Extract<Event, { kind: K }>) => void;
};
const handlers: Handlers = {
click: e => console.log(e.x, e.y),
keydown: e => console.log(e.key)
// โ missing 'keydown' would be a compile error
};
handlers.click({ kind: 'click', x: 1, y: 2 });
handlers.keydown({ kind: 'keydown', key: 'Enter' });
EOF
npx tsc --noEmit mapped.ts
# (no errors)
# ============================================
# PART 7: COMPILE AND RUN
# ============================================
npx tsc exhaustive.ts du.ts never-return.ts machine.ts mapped.ts
node exhaustive.js
# [ Waiting ]
# [ Ready ]
node du.js
# [ 1,2 ]
node never-return.js
# [ hi 42 ]
node machine.js
# [ Ready ]
# [ Loading... ]
# [ Data: hello ]
# [ Error: oops ]
node mapped.js
# [ 1 2 ]
# [ Enter ]
Quick Reference
never Facts
| Fact | Meaning |
|---|---|
| Empty type | No values |
| Bottom type | Subtype of everything |
| Assignable to | Any type |
| Assignable from | Nothing |
| Union identity | A | never = A |
| Intersection absorbing | A & never = never |
Where never Appears
| Context | Example |
|---|---|
| Function return | function f(): never |
| Impossible intersection | string & number |
| Exhaustiveness | After all cases |
| Unreachable code | After return/throw |
| Conditional filter | T extends X ? never : T |
assertNever
| Step | Code |
|---|---|
| Definition | function assertNever(x: never): never { throw x; } |
| Usage | default: return assertNever(x); |
| Trigger | Add a new union branch |
| Error | “Argument of type ‘X’ is not assignable to ‘never'” |
Exhaustiveness Patterns
| Pattern | Example |
|---|---|
assertNever | default: return assertNever(x); |
| Inline never | const _: never = x; |
| If chain | if (a) return; if (b) return; assertNever(x); |
| All branches return | No default needed |
| Mapped type | { [K in T['kind']]: Handler } |
assertNever Variants
| Style | Code |
|---|---|
| Minimal | throw new Error('unreachable') |
| With JSON | throw new Error(JSON.stringify(x)) |
| Inline | const _: never = x; throw new Error(...) |
| Named parameter | function assertNever(x: never): never |
Union Elimination
| Before | After eliminating |
|---|---|
'a' | 'b' | 'c' | 'b' | 'c' (matched 'a') |
'b' | 'c' | 'c' (matched 'b') |
'c' | never (matched 'c') |
Common Discriminants for Exhaustiveness
| Field | Example |
|---|---|
kind | Shapes, events |
type | Redux actions |
status | Request states |
state | Form/machine states |
ok | Result success/failure |
never Distribution
| Input | Conditional |
|---|---|
never | never (special) |
string | number | Distributes over each |
| Guard against | [T] extends [never] |
Return Type Rules
| Return | Meaning |
|---|---|
void | No meaningful return |
undefined | Returns undefined |
never | Never returns (throws/loops) |
T | Returns T |
Mapped Type Exhaustiveness
| Step | Code |
|---|---|
| Base type | type Event = { kind: 'a' | 'b' } |
| Handler map | { [K in Event['kind']]: (e) => void } |
| Implementation | All keys required |
| Missing key | Compile error at declaration |
Error Messages
| Error | Cause |
|---|---|
Argument of type 'X' is not assignable to 'never' | Missing exhaustiveness |
Not all code paths return a value | Missing branch |
Unreachable code detected | Code after return/throw |
Property 'x' does not exist | Wrong narrowed branch |
Best Practices
โ Do This:
// Define assertNever once
function assertNever(x: never): never {
throw new Error(`Unhandled: ${JSON.stringify(x)}`);
} // โ
// Use it in every exhaustive switch
switch (e.kind) {
case 'a': return handleA(e);
case 'b': return handleB(e);
default: return assertNever(e);
} // โ
// Use never for throw-only functions
function fail(msg: string): never { throw new Error(msg); } // โ
// Use `never` for impossible intersections
type Impossible = string & number; // โ
= never
// Use mapped types for handler exhaustiveness
type Handlers = {
[K in Event['kind']]: (e: Extract<Event, { kind: K }>) => void;
}; // โ
// Test by adding a new union branch
// Every switch should fail to compile until updated // โ
// Use inline never when you don't want a helper
default: {
const _: never = x;
throw new Error('unreachable');
} // โ
โ Don’t Do This:
// Don't skip the default
switch (x.kind) {
case 'a': break;
case 'b': break;
// โ silent miss
} // โ
// Don't throw a generic error without assertion
default: throw new Error('unreachable'); // โ ๏ธ no compile-time check
// Don't use `as never` to silence errors
default: assertNever(x as never); // โ hides real issues
// Don't use `any` in default
default: { const _: any = x; } // โ no check
// Don't ignore exhaustiveness for critical paths
// Reducers, parsers, state machines need it // โ
// Don't type throwing functions as void
function fail(): void { throw new Error(); } // โ ๏ธ never is more precise
// Don't confuse never with void in returns
function f(): never { return; } // โ error
// Don't rely on implicit exhaustiveness
// Always add a default with assertNever // โ
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
Missing default | Silent miss | Add assertNever |
default: break | No check | Use assertNever |
as never to silence | Hides bugs | Fix the missing case |
Forgetting assertNever | No compile-time check | Use it everywhere |
void return for throws | Less precise | Use never |
never in conditional | Distributive surprise | Guard with [T] extends [never] |
| Missing return in one branch | Return type error | Add the branch |
| Adding union branch | Only some code catches it | Use mapped-type dispatch |
Real-World Examples
1. Define assertNever
function assertNever(x: never): never {
throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}
2. Exhaustive switch
switch (x.kind) {
case 'a': return a;
case 'b': return b;
default: return assertNever(x);
}
3. Inline never check
default: {
const _: never = x;
throw new Error('unreachable');
}
4. Function that never returns
function fail(msg: string): never { throw new Error(msg); }
5. Infinite loop
function loop(): never { while (true) {} }
6. Impossible intersection
type Empty = string & number; // never
7. Union identity
type A = string | never; // string
8. Filter nullish with never
type NonNullish<T> = T extends null | undefined ? never : T;
9. Exhaustive reducer
switch (action.type) {
case 'ADD': return add(state, action.item);
case 'REMOVE': return remove(state, action.id);
case 'CLEAR': return initial;
default: return assertNever(action);
}
10. Mapped-type handlers
type Handlers = {
[K in Event['kind']]: (e: Extract<Event, { kind: K }>) => void;
};
11. Handle all states
switch (state.status) {
case 'idle': ...
case 'loading': ...
case 'success': ...
case 'error': ...
default: return assertNever(state);
}
12. Unreachable code detection
return;
console.log('x'); // โ unreachable
13. never in catch
catch (e) {
if (e instanceof Error) handleError(e);
else throw e; // e might be never here
}
14. Guard against never distribution
type Safe<T> = [T] extends [never] ? 'empty' : 'has-value';
15. Exhaustive if chain
if (x === 'a') return 1;
if (x === 'b') return 2;
if (x === 'c') return 3;
return assertNever(x);
16. Add variant โ compile error
type Status = 'idle' | 'loading' | 'ready' | 'error' | 'cancelled';
// Now every switch fails to compile until updated
17. Throw-only helper
function invariant(cond: boolean, msg: string): asserts cond {
if (!cond) throw new Error(msg);
}
18. Exhaustive dispatch
function dispatch(cmd: Command): void {
switch (cmd.kind) {
case 'create': return create(cmd);
case 'update': return update(cmd);
case 'delete': return remove(cmd);
default: return assertNever(cmd);
}
}
19. State machine transition
function nextState(s: State, e: Event): State {
switch (s.status) {
// all cases
default: return assertNever(s);
}
}
20. Typescript catches missing branch
// Adding { kind: 'hover' } to Event makes every
// exhaustive switch fail with a precise error.
Visual: Exhaustiveness Flow
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ type Status = 'a' | 'b' | 'c' โ
โ โ
โ function f(s: Status) { โ
โ switch (s) { โ
โ case 'a': ... โ s: 'a' โ
โ case 'b': ... โ s: 'b' โ
โ case 'c': ... โ s: 'c' โ
โ default: โ s: never โ
โ } โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Adding a Variant
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Before: โ
โ type Status = 'a' | 'b' | 'c' โ
โ โ
โ switch (s) { case 'a': case 'b': case 'c': }โ
โ โ compiles โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โ add 'd'
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ After: โ
โ type Status = 'a' | 'b' | 'c' | 'd' โ
โ โ
โ switch (s) { case 'a': case 'b': case 'c': }โ
โ โ โ 'd' not assignable to never โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: never in Function Returns
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ function f(): never { throw new Error(); } โ
โ โ
โ โ never returns โ
โ โ assignable to any signature โ
โ โ code after call is unreachable โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ function f(): void { } โ
โ โ
โ โ returns undefined โ
โ โ NOT assignable where never is expected โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: never in Types
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ string & number โ never โ
โ string | never โ string โ
โ never | never โ never โ
โ never[] โ never[] โ
โ Array<never> โ never[] โ
โ Partial<never> โ {} โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: assertNever Variants
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Named helper โ
โ โ
โ function assertNever(x: never): never { โ
โ throw new Error(JSON.stringify(x)); โ
โ } โ
โ โ
โ default: return assertNever(x); โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Inline โ
โ โ
โ default: { โ
โ const _exhaustive: never = x; โ
โ throw new Error('unreachable'); โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ If chain โ
โ โ
โ if (x === 'a') return 1; โ
โ if (x === 'b') return 2; โ
โ return assertNever(x); โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Mapped-Type Exhaustiveness
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ type Event = โ
โ | { kind: 'click'; x: number; y: number } โ
โ | { kind: 'keydown'; key: string }; โ
โ โ
โ type Handlers = { โ
โ [K in Event['kind']]: โ
โ (e: Extract<Event, { kind: K }>) => voidโ
โ }; โ
โ โ
โ const handlers: Handlers = { โ
โ click: e => ..., โ
โ keydown: e => ... โ
โ }; โ
โ โ
โ Missing 'keydown' โ compile error โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Where never Appears
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Value level โ
โ โ function returns never โ
โ โ unreachable code โ
โ โ after exhaustiveness โ
โ โ
โ Type level โ
โ โ impossible intersections โ
โ โ union identity โ
โ โ conditional filter โ
โ โ distributive special case โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Reducer Exhaustiveness
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ function reducer(state, action) { โ
โ switch (action.type) { โ
โ case 'ADD': return add(...); โ
โ case 'REMOVE': return remove(...); โ
โ case 'CLEAR': return initial; โ
โ default: return assertNever(action);โ
โ } โ
โ } โ
โ โ
โ Add action โ compile error here โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Never in Conditional Types
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ type NonNullish<T> = โ
โ T extends null | undefined ? never : T; โ
โ โ
โ NonNullish<string | null> โ string โ
โ NonNullish<number | undefined> โ number โ
โ NonNullish<null | undefined> โ never โ
โ NonNullish<never> โ never โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Check for Exhaustiveness
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ All branches handled? โ
โ โ โ
โ โโโ Yes โโโบ default: never โ
โ โ (no error) โ
โ โ โ
โ โโโ No โโโบ default: remaining-type โ
โ โ not assignable to never โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Concept | Meaning |
|---|---|
never | Empty type โ no values |
| Bottom type | Subtype of every type |
| Function return | never = never returns |
| Exhaustiveness | never after all cases |
assertNever | Helper that enforces exhaustiveness |
| Union identity | A | never = A |
| Intersection absorbing | A & never = never |
| Distributive | never distributes to never |
| Unreachable | Code after throw/return is never |
Key takeaways:
neveris the empty type โ no value belongs to it, and it’s assignable to everything- Functions that throw or loop forever return
never - After a
switchhandles every branch of a union, the remaining type isnever assertNeverturns exhaustiveness into a compile-time check โ assign the remainder to aneverparameter- Adding a new variant to a union triggers compile errors in every exhaustive switch โ a free checklist
- Inline
const _: never = xworks without a helper - Mapped types over a discriminant force an entry per variant
neverappears in impossible intersections, conditional filters, and unreachable code- Distribution through conditional types has special behavior โ guard with
[T] extends [never] - Use
never, notvoid, for functions that never return โ more precise, more useful - Don’t silence errors with
as neverโ fix the missing case - Exhaustiveness works within a function scope โ use mapped-type handlers for cross-function dispatch
Remember: never is the empty type, and its greatest use is proving your code handles everything. After a switch over a union, the compiler narrows the remainder to never โ and if it isn’t never, you missed a case. That’s the whole trick: assertNever forces the compiler to check. Add a new variant and every switch fails to compile until updated. It’s the single most useful pattern for keeping discriminated unions, state machines, and reducers correct as they evolve.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!