• _hello
  • _about-me
  • _projects
  • _blog
  • _support
_contact-me
find me in:
@faeztgh
cd ../blog~/blog/discriminated-unions-typescript
  • TypeScript
  • JavaScript

Discriminated Unions: TypeScript's Best Modelling Tool

A discriminated union pairs a set of variants with a shared literal tag, so the compiler can narrow to exactly one shape. It's the cleanest way to model state, results, and events in TypeScript.

FFaez Tgh·Jul 4, 20264 min read·797 words
// share: post linkedin
TypeScript discriminated unions making illegal states impossible

If I had to name the single most underused feature in everyday TypeScript, it would be the discriminated union. It's the tool that turns "this object might have these fields" into "this object is exactly one of these shapes," and it's how you make illegal states impossible to construct rather than merely discouraged by a comment.

The loose type that causes the bug#

Here's a shape you've probably written — a request state with a few optional fields:

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

It looks reasonable and it's quietly wrong. Nothing stops { loading: true, data: user, error: 'oops' } — loading and loaded and errored at once. Every consumer has to defensively check all three fields in combinations that should never happen, and the one you forget is the bug.

The fix: a shared discriminant#

A discriminated union is a union of object types that all share one property — the discriminant — set to a distinct literal in each variant:

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

Now the impossible combinations are literally unrepresentable — there's no member of this union that carries both data and error. And when you switch on status, TypeScript narrows to the matching variant and lets you touch only that variant's fields:

ts
function render(state: RequestState) {
  switch (state.status) {
    case 'idle':
      return 'Ready';
    case 'loading':
      return 'Loading…';
    case 'success':
      return state.data.name; // ✅ `data` exists here, and only here
    case 'error':
      return state.error; // ✅ `error` exists here, and only here
  }
}

Access state.data in the error branch and it's a compile error. The type system now enforces what the comment used to only ask nicely for.

Exhaustiveness checking, for free#

The real payoff comes when you add a variant. Give the default branch a never assertion and the compiler will refuse to build until every case is handled:

ts
function render(state: RequestState) {
  switch (state.status) {
    case 'idle': return 'Ready';
    case 'loading': return 'Loading…';
    case 'success': return state.data.name;
    case 'error': return state.error;
    default: {
      const _exhaustive: never = state; // errors if a case is unhandled
      return _exhaustive;
    }
  }
}

Add a { status: 'cancelled' } variant later and every non-exhaustive switch in the codebase lights up red. That's the whole promise of static typing delivered on a plate: change the data model, and the compiler hands you the to-do list.

How it pairs with the rest of the toolbox#

Discriminated unions compose beautifully with the other TypeScript features worth knowing. When you build the union values as literals, as const and utility types keep the discriminant narrow to 'success' rather than widening it to string — without that narrowing, the switch can't discriminate. And when you want to validate a constant map of variants against a shape while keeping each one's precise type, that's exactly the gap the satisfies operator was designed to close. For transforming the shapes themselves — dropping a field across every variant, say — the mapped-type technique in key removal in TypeScript applies cleanly.

When to reach for one#

Any time a value is "one of several kinds," a discriminated union is probably the right model:

  • Async/request state — the example above.
  • Result types — { ok: true; value: T } | { ok: false; error: E }.
  • Events and actions — a reducer's action type is a discriminated union on type, which is why useReducer and Redux feel so natural in TypeScript.
  • Parsed input — an AST node, a config entry, a message from a socket.

The rule of thumb: if you find yourself writing an interface with several optional fields where only certain combinations are valid, stop and ask whether it's really a union of a few exact shapes. It usually is.

The takeaway#

Discriminated unions move the "this can't happen" from a comment into the type. You get precise field access per variant, exhaustive checking that turns model changes into a compiler-generated checklist, and code that reads as a clean switch instead of a thicket of optional-field guards. It's the closest TypeScript comes to a superpower — reach for it whenever a value is one-of-many. This is the kind of type-level modelling I lean on across my projects, and there's more of it in the skills that shape how I build.

FAQ#

What is a discriminated union in TypeScript?#

A discriminated union (also called a tagged union) is a union of object types that all share a common property — the discriminant — set to a distinct literal value in each variant. Switching on that property lets TypeScript narrow the value to exactly one shape and expose only that shape's fields.

Why use a discriminated union instead of optional fields?#

Optional fields allow invalid combinations to exist (e.g. both data and error present at once), forcing defensive checks everywhere. A discriminated union makes those illegal states unrepresentable, so the compiler enforces valid shapes instead of relying on convention.

How does exhaustiveness checking work?#

Assign the value to a variable of type never in a default branch. Since a fully-handled union narrows to never there, the assignment compiles. If you add a new variant and forget to handle it, the value is no longer never and TypeScript raises an error — pointing you at every switch that needs updating.

What's a good discriminant property name?#

Any literal-typed field works, but type, status, kind, and ok are common conventions. The only requirement is that each variant sets it to a different literal so the compiler can tell the variants apart.

References:

  • TypeScript Handbook — Discriminated Unions
  • TypeScript Handbook — Narrowing

#on this page

  • The loose type that causes the bug
  • The fix: a shared discriminant
  • Exhaustiveness checking, for free
  • How it pairs with the rest of the toolbox
  • When to reach for one
  • The takeaway
  • FAQ
  • What is a discriminated union in TypeScript?
  • Why use a discriminated union instead of optional fields?
  • How does exhaustiveness checking work?
  • What's a good discriminant property name?
A Practical Primer on Retrieval-Augmented Generation (RAG) olderCSS Container Queries: Components That Own Their Responsivenessnewer

# related-posts

3 more
  • 01
    The TypeScript satisfies Operator, and When to Use ItJun 20, 2026·4 min
  • 02
    Debounce vs Throttle: Taming Noisy Events in JavaScriptJun 5, 2026·5 min
  • 03
    TypeScript DecoratorsFeb 6, 2024·2 min