Dataslope logoDataslope

Discriminated Unions

Model state machines and domain events with tagged unions

Discriminated unions are the single most useful pattern in modern TypeScript. They let you model variants — states, events, results, commands — where each variant has different data, and the compiler helps you handle every case exhaustively.

The idea: define a union of object types, each with a discriminant field (a tag like kind or type) that uniquely identifies the variant. TypeScript's control-flow analysis narrows the union based on the discriminant, so inside a switch or if statement, you know exactly which variant you're handling — and the compiler proves you didn't miss any.

This is how you model state machines, API responses, domain events, parser results, and anything else with "one of several possibilities." Let's see why discriminated unions are so powerful.

The discriminant field

A discriminated union is a union of object types that share a common property — the discriminant — with distinct literal values for each variant.

Code Block
TypeScript 5.7

The kind field is the discriminant (also called a tag). Each variant has a different literal value: "circle", "rectangle", or "triangle". When you check shape.kind === "circle", TypeScript narrows the type to { kind: "circle"; radius: number }, so it knows shape.radius exists and is safe to access.

This is called narrowing by discriminant. The compiler tracks which variant you're in and gives you precise autocomplete and error checking for that variant's fields.

Why 'kind' or 'type'?

You can name the discriminant field anything you want, but kind and type are conventional. Some teams use tag, variant, or _type (with an underscore to avoid clashing with JavaScript's type keyword in older contexts). Pick one and stick with it across your codebase.

Exhaustiveness checking

The real power of discriminated unions is exhaustiveness checking: the compiler can verify you've handled every variant. If you add a new variant later and forget to update a switch statement, the compiler catches it.

Here's the trick: assign the unhandled case to never. If you've covered all variants, the code is unreachable and never is satisfied. If you missed a variant, the compiler complains that the leftover type isn't assignable to never.

Code Block
TypeScript 5.7

In the default case, we try to assign status to a variable of type never. If every variant has been handled, status is type never (the "empty" type), so the assignment succeeds. If we forgot a case, status still has a type (the unhandled variant), and assigning it to never fails with a compile error.

Try it: comment out one of the cases above and uncomment the code. The _exhaustive assignment will error, telling you exactly which variant you missed.

Add a variant, get a compile error

This is the dream: you add a new variant to the union (e.g., | { kind: "cancelled" }), and every switch statement that handles Status lights up with an error until you add the new case. The compiler becomes your refactoring assistant.

Modeling state machines

Discriminated unions shine when modeling state machines — systems that can be in one of several states, each with different valid data.

Consider a data-fetching flow: you start idle, transition to loading, and then land in either success or error. Each state has its own data:

Code Block
TypeScript 5.7

This pattern is often called RemoteData (borrowed from Elm). It makes illegal states unrepresentable: you can't have status: "success" without data, and you can't have status: "error" without an error message. Contrast this with a naive approach:

// ❌ Bad: allows impossible states
type BadFetchState<T> = {
  loading: boolean;
  data: T | null;
  error: string | null;
};

With the naive version, you can end up with loading: false, data: null, error: null (which state is that?) or loading: true, data: "some data", error: "some error" (a success and an error at the same time?). The discriminated union enforces that exactly one variant is active at a time.

Avoid boolean soup

When you see multiple booleans (isLoading, isSuccess, isError) or nullable fields that represent mutually exclusive states, it's a sign you should use a discriminated union instead. The compiler will enforce that only one state is active.

Domain events

Discriminated unions are perfect for modeling domain events — things that happen in your application, where each event carries different data.

Code Block
TypeScript 5.7

Each event is self-describing: the type field tells you what happened, and the rest of the fields carry event-specific data. You can pass these events to an analytics service, a logger, or an event-sourcing store, and the consumer can handle each type appropriately.

Multi-file example: RemoteData ADT

Let's build a more complete example across multiple files. We'll define a RemoteData<T> type in one module and a set of helpers in another.

Code Block
TypeScript 5.7

The remoteData.ts module defines the union and a few helpers. The main.ts module imports them and simulates a fetch lifecycle. Notice how the type guards (isLoading, isSuccess) narrow the type so you can safely access variant-specific fields.

Type guards refresh

A type guard is a function that returns x is SomeType. When you call it in an if statement, TypeScript narrows the type of x to SomeType in the true branch. We covered this in the Type Narrowing chapter — discriminated unions and type guards work together beautifully.

Challenge: Modeling a notification system

Now it's your turn. You'll model a notification system with multiple notification types, then write a function to render each one.

Challenge
TypeScript 5.7
Notification renderer

You're building a notification system. Notifications can be:

  • info: just a message
  • warning: a message and a severity level (1-5)
  • error: a message and a stack trace

Your tasks:

  1. In notification.ts, define a discriminated union Notification with a type discriminant.
  2. In main.ts, implement renderNotification to return a formatted string for each variant.
  3. The test will check that all three variants render correctly.

Multiple choice check

QuestionSelect one

What happens if you add a new variant to a discriminated union but forget to handle it in a switch statement with exhaustiveness checking?

The code compiles and runs, but crashes at runtime when the new variant is encountered.

The code fails to compile because the unhandled variant isn't assignable to never.

The new variant is ignored, and the default case handles it silently.

When to use discriminated unions

Discriminated unions are the right choice whenever you have:

  • Mutually exclusive states. Loading vs. success vs. error. Draft vs. published vs. archived.
  • Domain events. UserLoggedIn vs. UserLoggedOut vs. PasswordReset. Each event has its own payload.
  • Commands or actions. AddItem vs. RemoveItem vs. ClearCart. Each command has different parameters.
  • Parser results. Success vs. SyntaxError vs. EOF. Each outcome carries different data.

The pattern is always the same: define a union of object types with a shared discriminant field, use switch or if to narrow by the discriminant, and add exhaustiveness checking in the default case to catch missing variants.

The most powerful pattern

If you learn one advanced TypeScript pattern, make it discriminated unions. They combine the flexibility of unions with the precision of narrowing, and they make illegal states unrepresentable. You'll use them everywhere once you see how they simplify state management and domain modeling.

Recap

Discriminated unions let you model variants — states, events, commands, results — where each variant has different data and a unique tag. The compiler uses the tag (the discriminant) to narrow the union, so you get precise type checking and autocomplete for each variant.

Key takeaways:

  • Discriminant field: A shared property with distinct literal values (e.g., kind: "circle" | "rectangle").
  • Narrowing by discriminant: switch or if checks on the discriminant narrow the union to a single variant.
  • Exhaustiveness checking: Assign the unhandled case to never to force the compiler to verify all variants are covered.
  • State machines: Model async states, fetch flows, and UI states as discriminated unions to make illegal states unrepresentable.
  • Domain events: Model events as discriminated unions to ensure each event carries the right payload.

Discriminated unions are the backbone of type-safe state management in TypeScript. They're concise, composable, and compiler-verified. Master them, and you'll write cleaner, safer code.

Next: Modeling Domains with Types, where we take the principles from discriminated unions and apply them to real-world domain modeling — evolving sloppy types into precise, self-documenting data structures that make illegal states impossible.

On this page