Menu

TypeScript Discriminated Unions: Tagged Unions Explained

A discriminated union is a union of object types that share a literal tag property, like kind or status. Checking the tag narrows the whole object. Learn the pattern, switch narrowing, exhaustive checks with never, and how to model API results, request state and state machines.

This page includes runnable editors - edit, run, and see output instantly.

A discriminated union (also called a tagged union) is a union of object types that all have one property in common, the tag, with a different literal value in each member. Checking the tag tells TypeScript which member you have, and the other properties of that member become available.

Inside case "circle", reading shape.width would be a compile error, because the circle member has no width. The tag is ordinary data: at runtime it is just a string property, and the switch is plain JavaScript.

What Makes a Union Discriminated

Narrowing on the tag works when three conditions hold:

  1. Every member is an object type.
  2. Every member has the same property name (the discriminant). The name is up to you: kind, type, status and tag are common.
  3. In each member, that property has a literal type: a string, number or boolean literal, or null/undefined.
type UiEvent =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string }
  | { type: "scroll"; delta: number };

type ApiResponse =
  | { ok: true; data: string }    // boolean literal tags
  | { ok: false; error: string };

type Message =
  | { version: 1; text: string }  // number literal tags
  | { version: 2; text: string; lang: string };

If one member declares the tag as plain string instead of a literal, comparing the tag no longer narrows the union, and member-specific properties stay out of reach (TS2339).

Narrowing on the Tag

Any check TypeScript understands on the tag narrows the object: switch, if/else, === and !==, even a destructured tag as long as it is a const:

function describe(e: UiEvent): string {
  if (e.type === "click") return `click at ${e.x},${e.y}`;

  const { type } = e;        // destructured tags narrow too
  if (type === "keypress") return `key ${e.key}`;

  return `scroll by ${e.delta}`; // only "scroll" is left
}

Checking for a property with "radius" in shape also narrows, but comparing a tag is clearer to read and makes exhaustive checking possible.

Exhaustive Checks

The biggest benefit of the pattern shows up when the union grows. With an explicit return type and no default, TypeScript knows the switch must cover every tag, so a new member without a case is a compile error:

index.ts(7,30): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

That message does not say which case is missing. The assertNever helper does, and it also throws at runtime if data from outside (JSON, an older client) carries a tag the types say cannot exist:

Add a fifth status without a case and the assertNever(state) line reports Argument of type '{ status: "..."; ... }' is not assignable to parameter of type 'never', naming the member. More on this in the never page.

Making Impossible States Impossible

RequestState above replaces a common, weaker shape:

// Every combination is allowed, including nonsense
type LooseState<T> = {
  loading: boolean;
  data?: T;
  error?: string;
};

const nonsense: LooseState<string[]> = { loading: true, data: ["a"], error: "timeout" };

With optional fields, "loading with data and an error" type-checks, and every reader has to guess which combinations can really happen. With the discriminated union, data exists only in the success state and error only in error, so code that reads state.data must first prove it is in the success state. The type itself documents the valid states.

Modeling Results: Success or Failure

A boolean tag is enough for "it worked or it did not". This Result type is a common alternative to throwing exceptions for expected failures such as invalid input:

The caller cannot read result.value without checking result.ok first, which is exactly the check that is easy to forget with exceptions or with null returns.

State Machines and Reducers

Two discriminated unions, one for states and one for actions, describe a state machine. A reducer switches on the action and returns the next state; the compiler checks that each returned object is a valid state:

{ ...state, name: "paused" } compiles only because state was narrowed to the playing member first, so the copy carries track and position. Returning { name: "paused" } alone would be an error: a paused state needs a track. This is the same shape Redux reducers and useReducer in React use.

The Widening Trap

The tag must stay a literal type. An object built into a variable with no annotation has its tag widened to string, and then it matches no member:

type Shape = { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };
declare function area(shape: Shape): number;

const c = { kind: "circle", radius: 2 }; // kind: string
area(c);
// error TS2345: Argument of type '{ kind: string; radius: number; }' is not assignable to parameter of type 'Shape'.

const ok1: Shape = { kind: "circle", radius: 2 };        // annotate the variable
const ok2 = { kind: "circle", radius: 2 } as const;      // or keep the literal with as const
area({ kind: "circle", radius: 2 });                     // or build it where Shape is expected

The underlying rule, that mutable properties widen, is explained on the literal types page.

Frequently Asked Questions

What is a discriminated union in TypeScript?

A union of object types where every member has the same property (the discriminant or tag) with a different literal type, for example { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }. Checking shape.kind === "circle" narrows shape to the circle member, so its other properties become available.

What is the difference between a union and a discriminated union?

A discriminated union is a union with one extra rule: all members share a property with a unique literal type. A plain union like Cat | Fish must be narrowed with in or custom type guards; a discriminated union is narrowed by comparing one property, and a switch over that property can be checked for exhaustiveness.

How do I make a switch over a discriminated union exhaustive?

Either give the function an explicit return type and no default (a missing case is then error TS2366), or add default: return assertNever(value) with function assertNever(x: never): never { throw ... }. The second form names the unhandled member in the error and also throws at runtime if unexpected data arrives.

Why is my discriminated union not narrowing?

The tag must be a literal type in every member. If an object was created without an annotation, its kind is widened to string and it no longer matches any member (TS2345/TS2322). Fix it with an annotation, as const, or by creating it where the union type is expected. A member typed kind: string also stops the union from narrowing on kind.

Can the discriminant be a boolean or a number?

Yes. Any literal type works: strings (kind: "circle"), numbers (version: 2), booleans (ok: true / ok: false), and even null or undefined. String tags are the most common because they are readable when logged or sent as JSON.

Coddy programming languages illustration

Learn to code with Coddy

GET STARTED