The Code Dealer Logo
All articles

Discriminated Unions: The TypeScript Feature Most Teams Underuse

Model state with a tag field instead of optional properties, and let the compiler prove your components can never render an impossible combination.

The Code Dealer
  • typescript
  • react
  • types

Most bugs in a typed codebase are not type errors. They are state errors — a component that renders a spinner and an error message at the same time, or a handler that reads data.id on a request that never finished. TypeScript will happily wave those through, because the shape you gave it allows them.

Discriminated unions close that gap. They are not an advanced trick; they are the default way to model anything that has more than one mode.

The problem with optional properties

Here is a shape almost every codebase has written at some point:

type RequestState = {
  loading: boolean
  error?: string
  data?: Course[]
}

Four fields, but far more than four possible values. loading: true with a populated data and an error is a legal value of this type. So is loading: false with everything undefined — the state that produces the blank screen nobody can reproduce. The type does not describe your program. It describes a superset of your program, and every consumer has to narrow it by hand with checks the compiler cannot verify.

Add a tag

A discriminated union gives every variant a shared literal field — the discriminant — and lists only the properties that variant actually has.

type RequestState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'success'; data: Course[] }

Four states, exactly four values. There is no way to construct a loading state that carries data, because that object is not assignable to any member.

When you check the discriminant, TypeScript narrows the union to a single member and the rest of the properties become available without a cast:

function CourseList({ state }: { state: RequestState }) {
  switch (state.status) {
    case 'idle':
    case 'loading':
      return <Skeleton />
    case 'error':
      return <ErrorBanner message={state.message} />
    case 'success':
      return <Grid courses={state.data} />
  }
}

state.message is only reachable inside the error branch. Try it in the success branch and the build fails.

Exhaustiveness for free

The real payoff arrives the day you add a fifth state. Assign the narrowed value to never in the default branch and every unhandled case becomes a compile error:

function assertNever(value: never): never {
  throw new Error(`Unhandled variant: ${JSON.stringify(value)}`)
}
 
default:
  return assertNever(state)

Add { status: 'refreshing'; data: Course[] } to the union and the compiler immediately points at every switch that has not been updated. That is a refactor the type system drives instead of one you drive with grep.

Choosing a discriminant

The discriminant must be a literal type — a string union, a number, or a boolean. String literals are the pragmatic choice because they survive being logged and serialised. Use the same key across a codebase (status, kind, type) so narrowing reads the same everywhere.

Where this pays off in a real app

Three places, immediately:

  • Data fetching. Your query hook already returns something like this. Map it to a union at the boundary once, and every component below stops writing defensive checks.
  • Form submission. Idle, submitting, server error, field errors, success — five states that are otherwise five booleans and 32 nominal combinations.
  • Webhook and event payloads. Stripe and Mux both send a type field. That field is a discriminant you get for free; declare the union and let the handler narrow instead of casting the payload.

The rule of thumb is simple: if two optional properties are never both present, you have a union hiding inside a record. Write the union down. The compiler will start finding the bugs you were finding in production.