Skip to content
TypeScript ts aliases-unions 4 min read

Discriminated Unions

A discriminated union (also called a tagged union or algebraic data type) is a union of object types that all share a common property whose type is a unique literal. That shared property — the discriminant — lets TypeScript figure out exactly which member you are dealing with from a single check, then narrow the value to that member and grant access to its unique fields. This pattern is one of the most powerful in the language: it models “a value that is exactly one of these shapes,” makes invalid states unrepresentable, and combines with a never check to guarantee you have handled every case. This page covers the discriminant, narrowing, and exhaustiveness.

Anatomy of a discriminated union

Each member is an object type, and each carries the same property name set to a different literal. That property is the discriminant.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rectangle"; width: number; height: number };

Here kind is the discriminant, and "circle" | "square" | "rectangle" are its literal values. The non-discriminant fields differ per member: only circles have radius, only rectangles have width and height. A value of type Shape is exactly one of these shapes — never a mix.

Narrowing on the discriminant

Check the discriminant with a switch or if, and TypeScript narrows the union to the matching member inside each branch. Member-specific properties become available only where they exist.

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2; // shape: { kind: "circle"; radius: number }
    case "square":
      return shape.side ** 2; // shape: square variant
    case "rectangle":
      return shape.width * shape.height; // shape: rectangle variant
  }
}

Because each case matches a literal value of kind, the compiler knows precisely which variant you are in and exposes only that variant’s fields. Accessing shape.radius in the "square" branch would be a compile error — the invalid combination is impossible to write.

Why a literal discriminant is required

The discriminant must be a literal type (typically a string or number literal) shared by every member. A plain string would not distinguish the members, so narrowing would fail.

// ✅ Works: discriminant is a literal in every member
type Ok = { status: "ok"; data: string };
type Err = { status: "error"; message: string };
type Resp = Ok | Err;

function handle(r: Resp) {
  if (r.status === "ok") return r.data; // narrowed to Ok
  return r.message; // narrowed to Err
}

The compiler builds an internal map from each literal discriminant value to its member. This is also why a discriminated union is more robust than checking for the presence of a property with the in operator — the tag is explicit and unambiguous.

Exhaustiveness checking with never

The real payoff comes when the union grows. By assigning the value to a never variable in the default branch, you turn “forgot a case” into a compile-time error. The never type accepts no value, so any unhandled member breaks the build.

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    case "rectangle":
      return shape.width * shape.height;
    default: {
      // If every case is handled, `shape` is narrowed to never here.
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${(shape as { kind: string }).kind}`);
    }
  }
}

If you later add { kind: "triangle"; base: number; height: number } to Shape but forget to handle it, shape in the default branch is no longer never, and the assignment const _exhaustive: never = shape fails:

// ❌ Error: Type '{ kind: "triangle"; ... }' is not assignable to type 'never'

This converts a whole class of “missing case” runtime bugs into immediate, localized compiler errors — the single biggest reason to model variant data this way.

Discriminated unions vs alternatives

Compared with optional-everything object types or loose flags, discriminated unions make illegal states unrepresentable and pair perfectly with exhaustiveness checks.

ApproachInvalid states possible?NarrowingExhaustiveness
One object, all props optionalYes (any combo)Manual if guardsNone
Boolean flags (isLoading, isError)Yes (both true)AwkwardNone
Discriminated unionNo — exactly one variantAutomatic on the tagYes, via never

A classic application is async state: { status: "loading" } | { status: "success"; data: T } | { status: "error"; error: Error }. You can never be loading and have data, and the compiler forces you to handle every status.

Name the discriminant consistently across your codebase — kind, type, or tag are common. Avoid type if it clashes with other tooling expectations, but whatever you pick, use it everywhere so narrowing is predictable.

Best Practices

  • Give every member a shared discriminant property typed as a unique string (or number) literal.
  • Narrow with switch (value.kind) for clarity and to enable exhaustiveness checks.
  • Add a default branch that assigns to const _x: never to force handling of every member.
  • Model async and request state as discriminated unions to forbid contradictory flag combos.
  • Keep discriminant names consistent (kind/type/tag) across the codebase.
  • Prefer a discriminated union over many optional properties or parallel boolean flags.
  • Combine with as const and literal types so discriminants stay narrow, not widened to string.
Last updated June 29, 2026
Was this helpful?