Discriminated Unions: Tagged State and Exhaustiveness

Learn to model mutually exclusive states with a shared literal 'tag' property so TypeScript can narrow each branch automatically and catch unhandled cases at compile time.

A discriminated union (also called a tagged union) is a union of object types that all share one property with a distinct literal type — the 'tag' or 'discriminant'. Once TypeScript sees you check that property, it narrows the whole object to the matching branch, which makes entire categories of 'is this field even defined right now' bugs impossible to write.

Why plain unions of optional fields go wrong

A common first attempt at modeling something like an async request's state is a single object type with a bunch of optional fields: { loading?: boolean; data?: string; error?: Error }. The type system allows every combination — loading: true alongside a populated data, or an error with no loading flag at all — even though only a few combinations are ever meaningful in practice. These are called 'impossible states': states the type system lets you construct but that never make sense in the running program.

Discriminated unions solve this by replacing 'a bag of optional fields' with 'a closed set of specific shapes'. Instead of one type that can (incorrectly) represent any combination, you define several object types, each with only the fields that are valid together, joined by a | union. Only real states are representable — there is no TypeScript value that is simultaneously loading and holding an error.

The discriminant property and narrowing

The discriminant is a property present in every member of the union, always with a distinct literal type (usually a string literal, sometimes a numeric or boolean literal). In the classic example, status plays this role: { status: "loading" }, { status: "success"; data: string }, and { status: "error"; error: Error } all share status, but each gives it a different literal value.

When you write if (result.status === "success"), TypeScript doesn't just check a runtime string — it narrows the static type of result inside that branch down to exactly { status: "success"; data: string }. Inside that if, result.data is accessible and typed as string; outside it, TypeScript refuses to let you touch .data at all, because not every branch of the union has it. A switch (result.status) works the same way, narrowing per case.

This narrowing is why the pattern is called 'discriminated': the compiler literally uses the discriminant's value to discriminate — to pick out — which member of the union you're dealing with, the same job a runtime typeof or instanceof check does for primitives and classes.

Exhaustiveness checking with never

Discriminated unions pair with a compile-time safety net called exhaustiveness checking. In a switch statement, you add a default branch whose only job is to assign the narrowed value to a variable typed never. If every case has been handled, TypeScript will have narrowed the remaining type down to nothing — literally the never type — and the assignment compiles. If you later add a new member to the union (a new status value) and forget to add a matching case, the default branch's narrowed type is no longer never, and the assignment becomes a type error, flagging the gap immediately.

This turns 'did I forget a case' from a runtime bug you discover in production into a compile-time error you see the moment you add the new union member — arguably the single biggest practical payoff of discriminated unions over plain conditional logic.

Where this shows up: UI state and beyond

Discriminated unions are the standard way to model UI state in frameworks like React: a component fetching data typically tracks one state value shaped as a union of idle, loading, success, and error variants rather than several independent booleans and nullable fields. Rendering becomes a single switch or a chain of narrowed checks, and it is structurally impossible to render a 'success' UI while data is undefined, because the type system won't let that state exist.

The same pattern appears well outside UI code: parsing results ({ ok: true; value: T } | { ok: false; error: string }), network message types tagged by a type or kind field, AST nodes tagged by node kind, and Redux-style actions tagged by type are all discriminated unions doing the same job — making the valid shapes explicit and the invalid ones unrepresentable.

Defining and narrowing a discriminated union

Every member shares the status property with a unique literal value — that's what makes this a discriminated union rather than an arbitrary union. Inside each case, result is narrowed to exactly that branch's shape, so result.data and result.error are only reachable where they're actually defined. The default branch assigning to a never-typed variable is the exhaustiveness check — it only compiles if every real case has already been handled above it.

type Result =
  | { status: "loading" }
  | { status: "success"; data: string }
  | { status: "error"; error: Error };

function render(result: Result): string {
  switch (result.status) {
    case "loading":
      return "Loading...";
    case "success":
      return `Data: ${result.data}`;
    case "error":
      return `Error: ${result.error.message}`;
    default: {
      const exhaustive: never = result;
      return exhaustive;
    }
  }
}

Code Examples

Each if check narrows result for the rest of that branch. By the final return, TypeScript has eliminated loading and success through the earlier checks, so it knows result must be the error variant and allows .error.message. Expected result: Got: 42 users

The rectangle case was never added to the switch. Because circle and square were narrowed away, the default branch's shape is still { kind: "rectangle"; ... }, which cannot be assigned to never — so the compiler flags the gap immediately instead of letting area() silently return NaN at runtime for rectangles.

Common mistake: Skipping the never-typed default branch entirely removes this safety net; a missing case then just falls through silently (or throws at runtime, if you're lucky enough to have a fallback throw) instead of failing to compile.

type Result =
  | { status: "loading" }
  | { status: "success"; data: string }
  | { status: "error"; error: Error };

function describe(result: Result): string {
  if (result.status === "loading") {
    return "Please wait...";
  }
  if (result.status === "success") {
    return `Got: ${result.data}`;
  }
  return `Failed: ${result.error.message}`;
}

console.log(describe({ status: "success", data: "42 users" }));
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rectangle"; width: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    default: {
      const exhaustive: never = shape;
      // Error: Type '{ kind: "rectangle"; width: number; height: number; }'
      // is not assignable to type 'never'.
      return exhaustive;
    }
  }
}

Discriminated union narrowing

The discriminant funnels the compiler down one branch at a time; fields from other branches simply aren't reachable there.

Result
  |
  |-- status: "loading" -----> { status: "loading" }
  |-- status: "success" -----> { status: "success"; data: string }
  '-- status: "error"   -----> { status: "error"; error: Error }

switch(result.status) narrows the union branch-by-branch,
so only the fields valid for that branch are visible.

Important Rules

Every member of a discriminated union must share a common property with a distinct literal type — string literals are the most common choice, but numeric or boolean literals work too.

Narrow with ===, switch, or other equality-based checks on the discriminant; typeof/instanceof narrowing doesn't apply here since these are all plain object shapes.

Add a never-typed exhaustiveness check in every switch over a discriminated union's discriminant so new union members that aren't yet handled fail to compile instead of failing silently at runtime.

Prefer a discriminated union over a single type with several optional fields whenever some combinations of those fields are never valid together — the union makes invalid combinations unrepresentable.

Async UI state in practice

React and other component-based UIs almost universally model fetch state as a discriminated union rather than independent booleans, because it guarantees the render function can never be called with a state that doesn't correspond to a real moment in the request's lifecycle — no flash of a loading spinner next to stale error text, because that combination simply cannot be constructed.

type State = { loading: boolean; data?: string; error?: Error };

type State = | { status: "loading" } | { status: "success"; data: string } | { status: "error"; error: Error };

The optional-fields version allows nonsensical states, like loading: true with error also set, that the type system should be preventing rather than permitting.

[multiple-choice] In type Result = { status: "loading" } | { status: "success"; data: string } | { status: "error"; error: Error }, what is the role of status? A. It's just a regular optional field B. It's the discriminant that lets TypeScript narrow the union based on its literal value C. It has no effect on type checking D. It must be a number, not a string

B. It's the discriminant that lets TypeScript narrow the union based on its literal value — status is present in every member with a distinct literal type, which is exactly what makes this a discriminated union — checking its value lets TypeScript narrow to the matching branch.

[multiple-choice] What does assigning the narrowed value to a never-typed variable in a switch's default case accomplish? A. It silences all type errors in the switch B. It converts the union to any C. It causes a compile error if any union member wasn't handled by an earlier case, catching missed cases D. It has no practical effect and is just a convention

C. It causes a compile error if any union member wasn't handled by an earlier case, catching missed cases — If every case is handled, the type remaining in default is never, and the assignment compiles. If a new union member is added without a matching case, that member's type can't be assigned to never, so compilation fails — flagging the gap.

[true-false] Modeling async state as { loading?: boolean; data?: string; error?: Error } prevents impossible combinations like being both loading and errored at the same time. A. True B. False

B. False — That shape allows any combination of the optional fields, including nonsensical ones. A discriminated union with distinct variants is what actually rules out impossible combinations.

Define a discriminated union Notification with three variants: { kind: "info"; message: string }, { kind: "warning"; message: string; code: number }, and { kind: "fatal"; message: string; stack: string }. Write a function format(n: Notification): string that handles all three with a switch and an exhaustiveness check.

type Notification = | { kind: "info"; message: string } | { kind: "warning"; message: string; code: number } | { kind: "fatal"; message: string; stack: string }; function format(n: Notification): string { switch (n.kind) { case "info": return Info: ${n.message}; case "warning": return Warning ${n.code}: ${n.message}; case "fatal": return Fatal: ${n.message}\n${n.stack}; default: { const exhaustive: never = n; return exhaustive; } } }

Switch on n.kind, and give the default case a const exhaustive: never = n; line so a missed variant fails to compile.