inkspell
writing topics feed
MODE
THEME
← writing

TypeScript Type Narrowing, Exhaustively

How discriminated unions and exhaustive checks make illegal states unrepresentable — and catch bugs at compile time.

May 28, 2026 · 3 min read
typescript type-systems

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.

TypeScript code on a dark editor — union types glowing in a cold blue palette

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

A branching decision tree diagram — paths that converge into a single correct answer

Discriminated unions with exhaustive checks are worth the ceremony whenever:

  1. A value can be in one of several qualitatively different states
  2. Each state has different fields (not just different values of the same field)
  3. 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.

← Back to all writing
Last login: Thu, May 28, 00:00:00 on ttys001
user@inkspell:~/blog read typescript-type-narrowing.md
FILE: typescript-type-narrowing.md DATE: 2026-05-28 WORDS: 543 READ: ~3m
TAGS: [typescript] [type-systems]

TypeScript Type Narrowing, Exhaustively

// How discriminated unions and exhaustive checks make illegal states unrepresentable — and catch bugs at compile time.

» BEGIN OUTPUT

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.

TypeScript code on a dark editor — union types glowing in a cold blue palette

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

A branching decision tree diagram — paths that converge into a single correct answer

Discriminated unions with exhaustive checks are worth the ceremony whenever:

  1. A value can be in one of several qualitatively different states
  2. Each state has different fields (not just different values of the same field)
  3. 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.

END OF FILE
user@inkspell:~/blog $
$ cd ..
phone down  ·  breathe  ·  begin
☸︎
typescript ☸︎ type-systems

TypeScript Type Narrowing, Exhaustively

☸︎

How discriminated unions and exhaustive checks make illegal states unrepresentable — and catch bugs at compile time.

session · 3 min sitting · May 28, 2026
— enter the practice —

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.

TypeScript code on a dark editor — union types glowing in a cold blue palette

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

A branching decision tree diagram — paths that converge into a single correct answer

Discriminated unions with exhaustive checks are worth the ceremony whenever:

  1. A value can be in one of several qualitatively different states
  2. Each state has different fields (not just different values of the same field)
  3. 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.

☸︎
the session ends
learn  ·  unlearn  ·  return
return when ready
☠︎ LARTS-SERVER BBS v2.3.1 — root console
⚠︎ Unauthorised access will be met with creative, legally ambiguous, and frankly disproportionate solutions.
Last login: 2026-05-28 00:00:00 from somewhere you'll regret  ·  session pid 1320
⚡ luser activity is being monitored. it always was. 7 LARTs administered today.
☠︎ BOFH EXCUSE #1154 :: someone enabled DHCP on the wrong VLAN again
root@larts-server:/var/rants # cat typescript-type-narrowing.txt  # and what was your username again?
SUBJECT: TypeScript Type Narrowing, Exhaustively
DATE: 2026-05-28 00:00:00 READ: ~3 min of your billable downtime WORDS: 543
TAGS: [typescript] [type-systems]
// How discriminated unions and exhaustive checks make illegal states unrepresentable — and catch bugs at compile time.
☠︎ ::: BEGIN TRANSMISSION — touch nothing ::: ☠︎

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.

TypeScript code on a dark editor — union types glowing in a cold blue palette

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

A branching decision tree diagram — paths that converge into a single correct answer

Discriminated unions with exhaustive checks are worth the ceremony whenever:

  1. A value can be in one of several qualitatively different states
  2. Each state has different fields (not just different values of the same field)
  3. 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.

☠︎ ::: END OF FILE — this never happened ::: ☠︎
[2026-05-28 00:00:00] SYSTEM: days since last 'accidental' BIOS flash: 3
[2026-05-28 00:00:00] SYSTEM: your read has been logged against your permanent record.
root@larts-server:/var/rants # logout  # don't let the airlock hit you
⛤︎
the circle is opened
☾ return to the archive ☽
☾ typescript ☽ ☾ type-systems ☽
⛤︎   SIGNUM   ⛤︎

TypeScript Type Narrowing, Exhaustively

☾   ⛤︎   ☽

How discriminated unions and exhaustive checks make illegal states unrepresentable — and catch bugs at compile time.

May 28, 2026 ⛤︎ 3 min rite ⛤︎ 543 words

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.

TypeScript code on a dark editor — union types glowing in a cold blue palette

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

A branching decision tree diagram — paths that converge into a single correct answer

Discriminated unions with exhaustive checks are worth the ceremony whenever:

  1. A value can be in one of several qualitatively different states
  2. Each state has different fields (not just different values of the same field)
  3. 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.

☾  ⛤︎  ────────  ⛤︎  ☽
so it is written · so it is bound
☾ return to the archive ☽
inkspell
© 2026 inkspell RSS