TypeScript Type Narrowing, Exhaustively
How discriminated unions and exhaustive checks make illegal states unrepresentable — and catch bugs at compile time.
One of TypeScript’s most useful features is one that often goes unremarked: the compiler can narrow a type inside a conditional block, and it will tell you when your narrowing is incomplete.
Let’s start with a common pattern — a result type.
The union
type Ok<T> = { kind: 'ok'; value: T };
type Err<E> = { kind: 'error'; error: E };
type Result<T, E> = Ok<T> | Err<E>;
The kind field is the discriminant — a literal string that TypeScript uses to tell the variants apart. With it, any conditional on kind narrows the type completely:
function unwrap<T>(result: Result<T, string>): T {
if (result.kind === 'ok') {
return result.value; // TypeScript knows: result is Ok<T>
}
throw new Error(result.error); // TypeScript knows: result is Err<string>
}
The exhaustive check
Now suppose you add a third variant:
type Pending = { kind: 'pending' };
type Result<T, E> = Ok<T> | Err<E> | Pending;
The unwrap function above compiles fine — TypeScript is happy to let the Pending case fall through. This is a silent bug waiting to happen.
The fix is an exhaustive check:
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
function unwrap<T>(result: Result<T, string>): T {
switch (result.kind) {
case 'ok': return result.value;
case 'error': throw new Error(result.error);
case 'pending': throw new Error('Not settled yet');
default: return assertNever(result);
}
}
The never type is TypeScript’s bottom type — a value that can never exist. Passing result to assertNever after all named cases are handled is valid only if TypeScript can prove result is never at that point. Add a new variant without updating the switch and the compiler will error on the assertNever call:
Argument of type 'Loading' is not assignable to parameter of type 'never'.
The illegal state becomes a compile error, not a runtime surprise.
Narrowing with type predicates
Sometimes you need to narrow outside a switch. TypeScript lets you write a type predicate:
function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
return result.kind === 'ok';
}
const results: Result<number, string>[] = [/* ... */];
const values = results.filter(isOk).map(r => r.value);
// ^^^^^^^ TypeScript knows this is T
The result is Ok<T> return annotation is a predicate — it tells TypeScript that when isOk returns true, the argument has been narrowed to Ok<T>. Without it, filter would return Result<number, string>[], and .map(r => r.value) would fail.
When to reach for this
Discriminated unions with exhaustive checks are worth the ceremony whenever:
- A value can be in one of several qualitatively different states
- Each state has different fields (not just different values of the same field)
- You add new states over the lifetime of the code
The classic examples are UI states (idle | loading | success | error), parser results, and domain event types. In all these cases, the compiler becomes a collaborator — it tells you which callsites you forgot to update.
That’s worth more than any test for the case you didn’t think to write.
TypeScript Type Narrowing, Exhaustively
// How discriminated unions and exhaustive checks make illegal states unrepresentable — and catch bugs at compile time.
One of TypeScript’s most useful features is one that often goes unremarked: the compiler can narrow a type inside a conditional block, and it will tell you when your narrowing is incomplete.
Let’s start with a common pattern — a result type.
The union
type Ok<T> = { kind: 'ok'; value: T };
type Err<E> = { kind: 'error'; error: E };
type Result<T, E> = Ok<T> | Err<E>;
The kind field is the discriminant — a literal string that TypeScript uses to tell the variants apart. With it, any conditional on kind narrows the type completely:
function unwrap<T>(result: Result<T, string>): T {
if (result.kind === 'ok') {
return result.value; // TypeScript knows: result is Ok<T>
}
throw new Error(result.error); // TypeScript knows: result is Err<string>
}
The exhaustive check
Now suppose you add a third variant:
type Pending = { kind: 'pending' };
type Result<T, E> = Ok<T> | Err<E> | Pending;
The unwrap function above compiles fine — TypeScript is happy to let the Pending case fall through. This is a silent bug waiting to happen.
The fix is an exhaustive check:
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
function unwrap<T>(result: Result<T, string>): T {
switch (result.kind) {
case 'ok': return result.value;
case 'error': throw new Error(result.error);
case 'pending': throw new Error('Not settled yet');
default: return assertNever(result);
}
}
The never type is TypeScript’s bottom type — a value that can never exist. Passing result to assertNever after all named cases are handled is valid only if TypeScript can prove result is never at that point. Add a new variant without updating the switch and the compiler will error on the assertNever call:
Argument of type 'Loading' is not assignable to parameter of type 'never'.
The illegal state becomes a compile error, not a runtime surprise.
Narrowing with type predicates
Sometimes you need to narrow outside a switch. TypeScript lets you write a type predicate:
function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
return result.kind === 'ok';
}
const results: Result<number, string>[] = [/* ... */];
const values = results.filter(isOk).map(r => r.value);
// ^^^^^^^ TypeScript knows this is T
The result is Ok<T> return annotation is a predicate — it tells TypeScript that when isOk returns true, the argument has been narrowed to Ok<T>. Without it, filter would return Result<number, string>[], and .map(r => r.value) would fail.
When to reach for this
Discriminated unions with exhaustive checks are worth the ceremony whenever:
- A value can be in one of several qualitatively different states
- Each state has different fields (not just different values of the same field)
- You add new states over the lifetime of the code
The classic examples are UI states (idle | loading | success | error), parser results, and domain event types. In all these cases, the compiler becomes a collaborator — it tells you which callsites you forgot to update.
That’s worth more than any test for the case you didn’t think to write.
TypeScript Type Narrowing, Exhaustively
How discriminated unions and exhaustive checks make illegal states unrepresentable — and catch bugs at compile time.
One of TypeScript’s most useful features is one that often goes unremarked: the compiler can narrow a type inside a conditional block, and it will tell you when your narrowing is incomplete.
Let’s start with a common pattern — a result type.
The union
type Ok<T> = { kind: 'ok'; value: T };
type Err<E> = { kind: 'error'; error: E };
type Result<T, E> = Ok<T> | Err<E>;
The kind field is the discriminant — a literal string that TypeScript uses to tell the variants apart. With it, any conditional on kind narrows the type completely:
function unwrap<T>(result: Result<T, string>): T {
if (result.kind === 'ok') {
return result.value; // TypeScript knows: result is Ok<T>
}
throw new Error(result.error); // TypeScript knows: result is Err<string>
}
The exhaustive check
Now suppose you add a third variant:
type Pending = { kind: 'pending' };
type Result<T, E> = Ok<T> | Err<E> | Pending;
The unwrap function above compiles fine — TypeScript is happy to let the Pending case fall through. This is a silent bug waiting to happen.
The fix is an exhaustive check:
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
function unwrap<T>(result: Result<T, string>): T {
switch (result.kind) {
case 'ok': return result.value;
case 'error': throw new Error(result.error);
case 'pending': throw new Error('Not settled yet');
default: return assertNever(result);
}
}
The never type is TypeScript’s bottom type — a value that can never exist. Passing result to assertNever after all named cases are handled is valid only if TypeScript can prove result is never at that point. Add a new variant without updating the switch and the compiler will error on the assertNever call:
Argument of type 'Loading' is not assignable to parameter of type 'never'.
The illegal state becomes a compile error, not a runtime surprise.
Narrowing with type predicates
Sometimes you need to narrow outside a switch. TypeScript lets you write a type predicate:
function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
return result.kind === 'ok';
}
const results: Result<number, string>[] = [/* ... */];
const values = results.filter(isOk).map(r => r.value);
// ^^^^^^^ TypeScript knows this is T
The result is Ok<T> return annotation is a predicate — it tells TypeScript that when isOk returns true, the argument has been narrowed to Ok<T>. Without it, filter would return Result<number, string>[], and .map(r => r.value) would fail.
When to reach for this
Discriminated unions with exhaustive checks are worth the ceremony whenever:
- A value can be in one of several qualitatively different states
- Each state has different fields (not just different values of the same field)
- You add new states over the lifetime of the code
The classic examples are UI states (idle | loading | success | error), parser results, and domain event types. In all these cases, the compiler becomes a collaborator — it tells you which callsites you forgot to update.
That’s worth more than any test for the case you didn’t think to write.
One of TypeScript’s most useful features is one that often goes unremarked: the compiler can narrow a type inside a conditional block, and it will tell you when your narrowing is incomplete.
Let’s start with a common pattern — a result type.
The union
type Ok<T> = { kind: 'ok'; value: T };
type Err<E> = { kind: 'error'; error: E };
type Result<T, E> = Ok<T> | Err<E>;
The kind field is the discriminant — a literal string that TypeScript uses to tell the variants apart. With it, any conditional on kind narrows the type completely:
function unwrap<T>(result: Result<T, string>): T {
if (result.kind === 'ok') {
return result.value; // TypeScript knows: result is Ok<T>
}
throw new Error(result.error); // TypeScript knows: result is Err<string>
}
The exhaustive check
Now suppose you add a third variant:
type Pending = { kind: 'pending' };
type Result<T, E> = Ok<T> | Err<E> | Pending;
The unwrap function above compiles fine — TypeScript is happy to let the Pending case fall through. This is a silent bug waiting to happen.
The fix is an exhaustive check:
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
function unwrap<T>(result: Result<T, string>): T {
switch (result.kind) {
case 'ok': return result.value;
case 'error': throw new Error(result.error);
case 'pending': throw new Error('Not settled yet');
default: return assertNever(result);
}
}
The never type is TypeScript’s bottom type — a value that can never exist. Passing result to assertNever after all named cases are handled is valid only if TypeScript can prove result is never at that point. Add a new variant without updating the switch and the compiler will error on the assertNever call:
Argument of type 'Loading' is not assignable to parameter of type 'never'.
The illegal state becomes a compile error, not a runtime surprise.
Narrowing with type predicates
Sometimes you need to narrow outside a switch. TypeScript lets you write a type predicate:
function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
return result.kind === 'ok';
}
const results: Result<number, string>[] = [/* ... */];
const values = results.filter(isOk).map(r => r.value);
// ^^^^^^^ TypeScript knows this is T
The result is Ok<T> return annotation is a predicate — it tells TypeScript that when isOk returns true, the argument has been narrowed to Ok<T>. Without it, filter would return Result<number, string>[], and .map(r => r.value) would fail.
When to reach for this
Discriminated unions with exhaustive checks are worth the ceremony whenever:
- A value can be in one of several qualitatively different states
- Each state has different fields (not just different values of the same field)
- You add new states over the lifetime of the code
The classic examples are UI states (idle | loading | success | error), parser results, and domain event types. In all these cases, the compiler becomes a collaborator — it tells you which callsites you forgot to update.
That’s worth more than any test for the case you didn’t think to write.
TypeScript Type Narrowing, Exhaustively
How discriminated unions and exhaustive checks make illegal states unrepresentable — and catch bugs at compile time.
One of TypeScript’s most useful features is one that often goes unremarked: the compiler can narrow a type inside a conditional block, and it will tell you when your narrowing is incomplete.
Let’s start with a common pattern — a result type.
The union
type Ok<T> = { kind: 'ok'; value: T };
type Err<E> = { kind: 'error'; error: E };
type Result<T, E> = Ok<T> | Err<E>;
The kind field is the discriminant — a literal string that TypeScript uses to tell the variants apart. With it, any conditional on kind narrows the type completely:
function unwrap<T>(result: Result<T, string>): T {
if (result.kind === 'ok') {
return result.value; // TypeScript knows: result is Ok<T>
}
throw new Error(result.error); // TypeScript knows: result is Err<string>
}
The exhaustive check
Now suppose you add a third variant:
type Pending = { kind: 'pending' };
type Result<T, E> = Ok<T> | Err<E> | Pending;
The unwrap function above compiles fine — TypeScript is happy to let the Pending case fall through. This is a silent bug waiting to happen.
The fix is an exhaustive check:
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
function unwrap<T>(result: Result<T, string>): T {
switch (result.kind) {
case 'ok': return result.value;
case 'error': throw new Error(result.error);
case 'pending': throw new Error('Not settled yet');
default: return assertNever(result);
}
}
The never type is TypeScript’s bottom type — a value that can never exist. Passing result to assertNever after all named cases are handled is valid only if TypeScript can prove result is never at that point. Add a new variant without updating the switch and the compiler will error on the assertNever call:
Argument of type 'Loading' is not assignable to parameter of type 'never'.
The illegal state becomes a compile error, not a runtime surprise.
Narrowing with type predicates
Sometimes you need to narrow outside a switch. TypeScript lets you write a type predicate:
function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
return result.kind === 'ok';
}
const results: Result<number, string>[] = [/* ... */];
const values = results.filter(isOk).map(r => r.value);
// ^^^^^^^ TypeScript knows this is T
The result is Ok<T> return annotation is a predicate — it tells TypeScript that when isOk returns true, the argument has been narrowed to Ok<T>. Without it, filter would return Result<number, string>[], and .map(r => r.value) would fail.
When to reach for this
Discriminated unions with exhaustive checks are worth the ceremony whenever:
- A value can be in one of several qualitatively different states
- Each state has different fields (not just different values of the same field)
- You add new states over the lifetime of the code
The classic examples are UI states (idle | loading | success | error), parser results, and domain event types. In all these cases, the compiler becomes a collaborator — it tells you which callsites you forgot to update.
That’s worth more than any test for the case you didn’t think to write.