jsmanifest logojsmanifest

TypeScript Discriminated Unions: Make Illegal States Unrepresentable

Learn how TypeScript discriminated unions model state so invalid combinations simply won't compile. Master the discriminant pattern, exhaustiveness checking with never, and dramatically cleaner reducers.

A few weeks ago I was reviewing a pull request where a component crashed in production with the classic Cannot read properties of undefined. The bug wasn't a typo or a missing null check—it was something more subtle. The type allowed a combination of values that should never have existed in the first place. The data was loading: false, error: null, and data: undefined all at once, and nobody could tell me what that state was supposed to mean.

That's the trap of modeling state with a bag of optional properties. TypeScript happily lets you construct nonsense, and you find out at runtime. Discriminated unions fix this at the type level: you describe the states that can exist, and the compiler makes every other combination a compile error. Once I started reaching for them, an entire category of bugs disappeared from my code.

The Problem: Optional Properties That Lie

Here's the shape I see constantly. Imagine a hook that fetches a user:

interface UserState {
  loading: boolean
  data?: User
  error?: Error
}

It looks reasonable. But think about how many states this type actually describes. With one boolean and two optional fields, TypeScript considers all of these valid:

// Loading, but somehow also has data AND an error?
const weird: UserState = { loading: true, data: user, error: new Error() }
 
// Not loading, no data, no error — what does that even mean?
const empty: UserState = { loading: false }

The type permits eight combinations, but only three of them are real: loading, loaded with data, and failed with an error. Every consumer of this type has to defensively check all the fields because the compiler can't prove which ones are present. You end up writing code like if (!state.loading && state.data) and hoping you covered every case.

The core issue: the fields are independent when they should be linked. The presence of data should guarantee the absence of error, and vice versa. Optional properties can't express that relationship.

Enter the Discriminated Union

A discriminated union (also called a tagged union) is a union of object types that all share one common literal property—the discriminant. That shared property acts as a tag TypeScript can use to narrow the type. Let's remodel the user state:

type UserState =
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: Error }

Now the type says exactly what I mean. There are three states, no more. The data field only exists when status is 'success', and error only exists when status is 'error'. The illegal combinations from before are now compile errors:

// Error: 'data' does not exist on the loading variant
const weird: UserState = { status: 'loading', data: user }

The status field is the discriminant. It has to be a literal type ('loading', 'success', 'error')—not a plain string—because TypeScript narrows by comparing against those exact literals.

Here are the three states the union allows—and the only transitions between them. Every other shape is a compile error:

%% alt: State machine of the UserState discriminated union — loading transitions to success or error
flowchart TD
    Start([fetch user]) --> Loading["status: loading"]
    Loading -->|request succeeds| Success["status: success<br/>data: User"]
    Loading -->|request fails| Error["status: error<br/>error: Error"]
    Success --> Render([render profile])
    Error --> Retry([show error, offer retry])

Narrowing Just Works

The real payoff shows up when you consume the union. TypeScript automatically narrows the type inside a switch or if on the discriminant:

function renderUser(state: UserState): string {
  switch (state.status) {
    case 'loading':
      return 'Loading…'
    case 'success':
      // TypeScript knows `state.data` exists here
      return `Welcome, ${state.data.name}`
    case 'error':
      // …and `state.error` exists here
      return `Something went wrong: ${state.error.message}`
  }
}

No optional chaining, no non-null assertions, no defensive checks. Inside case 'success', state.data is guaranteed to be a User. Access state.error there and you get a compile error, because that field belongs to a different variant. The types carry the proof for you.

This is what people mean by the phrase "make illegal states unrepresentable." Instead of validating at runtime that your data is in a sensible shape, you design the type so bad shapes can't be built.

Exhaustiveness Checking with never

Here's the feature that turned me into a discriminated-union evangelist. Say a product manager asks you to add a 'refreshing' state next quarter. You add it to the union:

type UserState =
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: Error }
  | { status: 'refreshing'; data: User } // new

How do you find every switch that now needs updating? You let the compiler find them for you, using a never assertion in the default case:

function assertNever(value: never): never {
  throw new Error(`Unhandled state: ${JSON.stringify(value)}`)
}
 
function renderUser(state: UserState): string {
  switch (state.status) {
    case 'loading':
      return 'Loading…'
    case 'success':
      return `Welcome, ${state.data.name}`
    case 'error':
      return `Something went wrong: ${state.error.message}`
    default:
      // If every case is handled, `state` narrows to `never` here.
      // The moment a variant is unhandled, `state` is that variant,
      // and passing it to assertNever is a COMPILE error.
      return assertNever(state)
  }
}

The mechanism is elegant: once you've handled every known variant, the default branch is unreachable, so state narrows to never. assertNever only accepts never, so the code compiles. Add a new variant without handling it, and state is no longer never—the call fails to typecheck, and TypeScript points you at every switch that needs attention. Your refactor becomes a checklist the compiler generates.

I treat a failing assertNever as a feature, not a chore. It's the difference between shipping a half-finished refactor and being told about every consumer before you merge.

A Practical Example: A Typed Reducer

Discriminated unions shine brightest in reducers, where both the state and the actions are unions. Here's a small checkout flow:

type CheckoutState =
  | { step: 'cart'; items: CartItem[] }
  | { step: 'shipping'; items: CartItem[]; address: Address }
  | { step: 'payment'; items: CartItem[]; address: Address; method: PayMethod }
  | { step: 'confirmed'; orderId: string }
 
type CheckoutAction =
  | { type: 'addAddress'; address: Address }
  | { type: 'addPayment'; method: PayMethod }
  | { type: 'confirm'; orderId: string }
 
function reducer(state: CheckoutState, action: CheckoutAction): CheckoutState {
  switch (action.type) {
    case 'addAddress':
      // Only valid from the cart step — the type guides you
      if (state.step !== 'cart') return state
      return { step: 'shipping', items: state.items, address: action.address }
 
    case 'addPayment':
      if (state.step !== 'shipping') return state
      return { ...state, step: 'payment', method: action.method }
 
    case 'confirm':
      return { step: 'confirmed', orderId: action.orderId }
 
    default:
      return assertNever(action)
  }
}

The reducer walks the checkout state through one legal path—and each step's type carries exactly the data that step needs, nothing more:

%% alt: Checkout reducer state transitions from cart through shipping and payment to confirmed
flowchart LR
    Cart["cart<br/>items"] -->|addAddress| Shipping["shipping<br/>+ address"]
    Shipping -->|addPayment| Payment["payment<br/>+ method"]
    Payment -->|confirm| Confirmed(["confirmed<br/>orderId"])

Notice how the state variants only carry the data that's valid at that step. There's no address?: Address that might be undefined during payment—by the time you reach the 'payment' step, the type guarantees address and method both exist. Impossible transitions and missing data become compile errors instead of production incidents. This pairs beautifully with the config-modeling ideas in my post on the satisfies operator, which keeps literal types precise while still validating them.

Common Pitfalls

A few things trip people up when they start using discriminated unions:

Using a non-literal discriminant. If your tag is typed as string instead of a union of string literals, narrowing breaks:

// Bad: `status` is `string`, so TypeScript can't narrow
interface Loose { status: string; data?: User }
 
// Good: literal union enables narrowing
type Tight = { status: 'idle' } | { status: 'ready'; data: User }

Forgetting the discriminant is shared by every member. Every variant must have the same property name for the tag. Mixing status in one variant and kind in another defeats the whole mechanism. Pick one name—type, kind, status, _tag—and use it consistently.

Reaching for a boolean discriminant. A single boolean can only split a union in two, and it reintroduces the "which fields are set" ambiguity. Prefer a string literal even for two states; it reads better and scales when a third state inevitably appears.

Skipping the never check. Without an assertNever default, adding a variant fails silently—your switch just falls through. The exhaustiveness check is what makes the pattern safe to evolve. If you like this style of compiler-enforced correctness, you'll appreciate the related techniques in 10 TypeScript utility types for bulletproof code.

Wrapping Up

Discriminated unions are one of those rare features that make your code both safer and simpler at the same time. Instead of a wide type full of optional fields that any consumer has to defensively untangle, you get a precise set of states where the compiler proves which data is available in each branch. The never-based exhaustiveness check then turns every future change into a guided refactor.

The next time you catch yourself writing data?: alongside error?: alongside isLoading, pause and ask what states actually exist. Model those states as a union with a shared discriminant, and let TypeScript make the illegal ones unrepresentable. Your future self—debugging production at 2am—will thank you.


Enjoyed this? I write regularly about practical TypeScript and modern JavaScript at jsmanifest.