Skip to content

Discriminated Unions in TypeScript: Making Illegal States Impossible

Aman Kumar Singh5 min read
Part 4 of 9From the TypeScript Deep Dive series
Discriminated Unions in TypeScript: Making Illegal States Impossible — article by Aman Kumar Singh

If you learn one advanced TypeScript pattern, make it discriminated unions. Nothing else in the type system gives you as much bug prevention for as little effort. The idea is simple — a union of object types that each carry a common literal field marking which variant they are — but the payoff is large: you can model your domain so that impossible states become literally impossible to write, and the compiler forces you to handle every case you defined. Most of the "I forgot to handle that state" bugs in application code are exactly the bugs this pattern eliminates.

This is part of the TypeScript type system series, and it builds directly on the "make illegal states unrepresentable" idea from the pillar.

The problem with a bag of optional fields

Here is a shape you have almost certainly written, in one form or another:

interface RequestState {
  loading: boolean
  data?: User
  error?: string
}

It looks reasonable, and it is a trap. Think about how many combinations it allows. loading: true with data already set. loading: false with neither data nor error. Both data and error populated at once. None of those make sense in reality — a request is loading, or it succeeded with data, or it failed with an error, never two at once — but the type permits all of them. Every one of those nonsensical combinations is a state some component will eventually receive and render incorrectly. The type is describing far more possibilities than actually exist, and the gap between "what the type allows" and "what can really happen" is where bugs live.

The discriminated union fix

A discriminated union models the same data as a set of distinct variants, each tagged with a shared literal field — the discriminant.

type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: string }

The status field is the discriminant: a string literal that is different in each variant. Now the shape tells the truth. data exists only in the success variant. error exists only in the error variant. The loading and idle states carry neither. The impossible combinations from before — data with an error, loading with data — cannot be expressed, because no single variant has those fields together. You have shrunk the type down to exactly the states that can really occur, and the illegal ones are gone not because you remembered to avoid them but because they are unwritable.

Narrowing follows the discriminant

The second half of the payoff is how you read a discriminated union. When you check the discriminant, TypeScript narrows the type to the matching variant and gives you safe access to exactly that variant's fields.

function render(state: RequestState) {
  switch (state.status) {
    case "idle":
      return "Nothing yet"
    case "loading":
      return "Loading..."
    case "success":
      return state.data.name  // data is available here, and typed
    case "error":
      return state.error       // error is available here, and typed
  }
}

Inside case "success", TypeScript knows state is the success variant, so state.data is a User — no optional-chaining, no non-null assertion, no runtime check. Try to access state.data in the "error" case and it is a compile error, because that field does not exist there. The discriminant lets the compiler track exactly which shape you are holding at each point, and it hands you precisely the fields that shape has. This is the same pattern that makes component state and loading and error UI safe to write, because every branch is guaranteed to have the data it needs.

Exhaustiveness checking with never

The final piece turns the pattern from convenient into genuinely safe. You can make the compiler force you to handle every variant, so that adding a new variant later produces an error at every place that needs updating — instead of a silent fall-through bug.

The trick uses never, the empty type. In the default case, assign the value to a never:

function render(state: RequestState): string {
  switch (state.status) {
    case "idle": return "Nothing yet"
    case "loading": return "Loading..."
    case "success": return state.data.name
    case "error": return state.error
    default:
      const _exhaustive: never = state
      return _exhaustive
  }
}

If you have handled every variant, then by the time control reaches default, state has been narrowed to never — there is nothing left — so the assignment is valid. But the day someone adds a { status: "cancelled" } variant to RequestState and forgets to handle it, state in the default branch is now { status: "cancelled" }, which is not assignable to never, and you get a compile error pointing straight at the switch that needs updating. This is the mechanism that makes discriminated unions safe to evolve: the type system becomes a checklist that refuses to let you forget a case. It leans on the set-theory idea from the type system guide, where never is the empty set and narrowing away every option leaves exactly nothing.

Where to reach for it

Any time you have data that can be in one of several mutually exclusive states, a discriminated union is almost certainly the right model. Request and form states, the result of an operation that can succeed or fail, events of different kinds flowing through a handler, a UI component that renders differently by mode — all of these are unions of variants wearing the disguise of a loose object with optional fields. Whenever you notice a type where certain field combinations "shouldn't happen," that is the signal to reach for a discriminant. Model the states as distinct variants, tag them, and let the compiler enforce that only the real states exist and that every one of them gets handled.

Key takeaways

  • A loose shape like { loading: boolean; data?; error? } allows impossible combinations; a discriminated union removes them by construction.
  • Each variant carries a shared literal discriminant (e.g. status) and only the fields that make sense for that state.
  • Checking the discriminant narrows the type to that variant, giving safe, typed access to exactly its fields with no optional chaining.
  • Assigning the value to never in the default case gives exhaustiveness checking: adding a new variant later fails to compile everywhere it is unhandled.
  • Whenever certain field combinations "shouldn't happen," that is the signal to model the data as tagged variants.

Frequently asked questions

What is a discriminated union in TypeScript?

A union of object types that each carry a common literal field — the discriminant — marking which variant they are. Because each variant only has the fields that make sense for it, impossible combinations cannot be expressed, and checking the discriminant narrows to the exact variant.

Why are discriminated unions better than optional fields?

A shape with optional data and error fields allows nonsensical states like both set at once, or loading with data. A discriminated union makes each state carry exactly its own fields, so those impossible combinations cannot be written and every real state is modeled precisely.

How does exhaustiveness checking work?

In the default branch of a switch on the discriminant, assign the value to a variable typed never. If every variant is handled, the value has narrowed to never and it compiles. If someone adds a new variant and forgets to handle it, the value is no longer never and you get a compile error pointing at the switch.

When should I use a discriminated union?

Any time data can be in one of several mutually exclusive states: request and form states, success-or-failure results, events of different kinds, or a component that renders differently by mode. If a type has field combinations that "shouldn't happen," model it as tagged variants instead.

Related articles

The satisfies Operator in TypeScript: Check Without Widening — Aman Kumar Singh
Mapped Types in TypeScript: Transforming Every Property — Aman Kumar Singh
Conditional Types in TypeScript: extends, infer, and Distribution — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.