Home/Learn/JavaScript & TypeScript/Unions, Narrowing and Discriminated Unions

Unions, Narrowing and Discriminated Unions

Intermediate
TypeScript

Unions model "one of these", and narrowing is how the compiler follows your checks. Discriminated unions are the single most useful pattern in the language.

Overview

A union type says a value is one of several things, and TypeScript will not let you use it until you have proved which. Narrowing is the compiler tracking your typeof, in, and equality checks and updating the type inside each branch. Once you add a shared literal field — a "tag" — you get discriminated unions, which let you model states that cannot be combined incorrectly. A request that is loading cannot also have data; expressing that in the type means the impossible combination cannot be written, and the compiler enforces that you handle every case.

Narrowing

The compiler follows control flow. Ordinary JavaScript checks are what narrow.

typeof, in, instanceof, and custom guards
function format(value: string | number | null) {
  if (value === null) return '—'          // value: string | number after this
  if (typeof value === 'string') return value.trim()   // string here
  return value.toFixed(2)                 // number — the only thing left
}

// The narrowing tools
typeof x === 'string'
x instanceof Date
'role' in user                        // property presence
Array.isArray(x)
x === null / x !== undefined
x?.length                             // optional chaining narrows too

// Truthiness narrows, and has the classic trap
if (count) { }        // excludes 0 as well as undefined — usually a bug
if (count != null) { }  // excludes null AND undefined, keeps 0

// A custom type guard, for when the built-ins cannot express it
function isProblem(x: unknown): x is Problem {
  return typeof x === 'object' && x !== null && 'slug' in x
}
if (isProblem(data)) data.slug        // narrowed by the return type

Discriminated Unions

A shared literal field the compiler can switch on. This is how you make illegal states unrepresentable.

One tag field, and impossible states vanish
type RequestState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: Problem[] }
  | { status: 'error'; error: string }

function render(state: RequestState) {
  switch (state.status) {
    case 'idle':    return null
    case 'loading': return <Spinner />
    case 'success': return <List items={state.data} />   // data exists here
    case 'error':   return <Error msg={state.error} />   // error exists here
  }
}

// Compare with the shape people write first:
interface Bad { loading: boolean; data?: Problem[]; error?: string }
// This permits loading:true with data AND error set — a state that
// makes no sense but the compiler cannot object to.

// Result types, the same idea:
type Result<T> = { ok: true; value: T } | { ok: false; error: Error }

Exhaustiveness

Make the compiler fail when someone adds a case and forgets a branch.

The never assignment as a compile-time check
function label(state: RequestState): string {
  switch (state.status) {
    case 'idle':    return 'Ready'
    case 'loading': return 'Loading…'
    case 'success': return 'Done'
    case 'error':   return 'Failed'
    default: {
      const _exhaustive: never = state    // only compiles if nothing is left
      return _exhaustive
    }
  }
}

// Add { status: 'cancelled' } to RequestState and this file fails to
// compile — pointing at exactly the switch that needs a new branch.
// That is the payoff: adding a state finds every place that handles states.

Key Points to Remember

  • 1A union value cannot be used until narrowed; typeof, in, instanceof and equality checks all narrow
  • 2Truthiness checks exclude 0 and empty string too — use != null when those are valid values
  • 3A custom type guard is a function returning `x is T`, for checks the built-ins cannot express
  • 4A discriminated union has a shared literal tag, letting the compiler know which fields exist in each branch
  • 5Assigning the value to `never` in the default branch makes forgetting a new case a compile error

Interview Questions

Sign in to ask Aria
1

What is a discriminated union and what problem does it solve?

Medium
2

How do you make the compiler enforce that a switch handles every case of a union?

Hard
3

What is a user-defined type guard and when do you need one?

Medium

Ask Aria about Unions, Narrowing and Discriminated Unions

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…