Narrowing and Discriminated Unions in TypeScript

A
Author
26 Jul, 2026 1 min read 770 views

A union type says "this value is one of several shapes". Narrowing is how TypeScript figures out which one you have inside a branch.

Built-in narrowing

function format(value: string | number | Date) {
  if (typeof value === 'string') return value.trim();      // string
  if (value instanceof Date) return value.toISOString();   // Date
  return value.toFixed(2);                                   // number
}

Discriminated unions

Give every variant a common literal field (the discriminant). This models states far better than a bag of optional fields:

type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function render(state: RequestState<User[]>) {
  switch (state.status) {
    case 'loading':
      return 'Loading...';
    case 'success':
      return state.data.map((u) => u.name).join(', '); // data exists here
    case 'error':
      return `Failed: ${state.error}`;                  // error exists here
    case 'idle':
      return '';
  }
}

Impossible states, like "loading with an error", can't even be written.

Exhaustiveness checking

function assertNever(x: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}

// In the switch above:
default:
  return assertNever(state); // compile error if a new status is added and not handled

Custom type guards

interface Cat { meow(): void }
interface Dog { bark(): void }

function isCat(pet: Cat | Dog): pet is Cat {
  return 'meow' in pet;
}

if (isCat(pet)) pet.meow();

Type guards are only as correct as their implementation. For external data, a schema validator is safer than a hand-written guard.

Was this article helpful?

Comments

No comments yet. Have a question or a tip? Be the first to share it.

Leave a comment

Comments are reviewed before they appear.