Discriminated Unions in TypeScript

A discriminated union uses a shared literal property to tell TypeScript exactly which variant of a union you are working with. Learn the pattern, exhaustiveness checking, and when to use it.

7 min read

A discriminated union is a union of object types where every member shares a common property with a unique literal value. That common property, called the discriminant, tells TypeScript exactly which member you are working with when you check its value.

Think of it as a tagged union. Each variant carries a tag, and TypeScript reads the tag to narrow the type safely.

typescripttypescript
interface Circle {
  kind: "circle";
  radius: number;
}
 
interface Square {
  kind: "square";
  sideLength: number;
}
 
type Shape = Circle | Square;

Here, the kind field is the discriminant. Circle sets kind to "circle" and Square sets kind to "square". Checking that field tells TypeScript exactly which shape it is looking at.

Why the Pattern Exists

Without a discriminant, a plain union of objects is hard to work with safely. A common but flawed approach is to put every possible field on one type and mark them all optional:

typescripttypescript
interface BadShape {
  kind: "circle" | "square";
  radius?: number;
  sideLength?: number;
}
 
function getArea(shape: BadShape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
  }
}

Checking kind looks like it should narrow the type, but it does not, because radius and sideLength are both optional on the same type regardless of which kind is set:

texttext
'shape.radius' is possibly 'undefined'.

TypeScript cannot know that radius is present just because kind is "circle", since nothing in the type actually links the two fields together. The discriminant does not narrow anything here.

The discriminated union fixes this by splitting each variant into its own type with its own required fields.

Narrowing with a Discriminant

Once each variant has its own type, checking the discriminant narrows to the exact member:

typescripttypescript
function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
  }
}
 
console.log(getArea({ kind: "circle", radius: 5 }));  // 78.5398...
console.log(getArea({ kind: "square", sideLength: 4 })); // 16

Inside the circle case, TypeScript knows shape is a Circle, so its radius field is accessible and typed as a number. Inside the square case, shape is narrowed to Square with its sideLength field instead.

Discriminated union narrowing in a switch

The flow is: start with the union, check the discriminant, and TypeScript narrows to the specific variant in each branch.

Real-World Example: Network State

Discriminated unions model UI states well, since each state naturally carries its own data shape. A typical network request moves through four distinct states:

typescripttypescript
type NetworkState =
  | { state: "idle" }
  | { state: "loading" }
  | { state: "success"; data: string[] }
  | { state: "error"; message: string };

Only the success variant has a data field, and only the error variant has a message field. Switching on the discriminant lets each branch use its own fields safely:

typescripttypescript
function render(state: NetworkState) {
  switch (state.state) {
    case "idle": return "Ready to fetch.";
    case "loading": return "Loading...";
    case "success": return `Loaded ${state.data.length} items.`;
    case "error": return `Error: ${state.message}`;
  }
}

TypeScript enforces this at compile time: accessing the message field is rejected in any branch other than the one where state has already been narrowed to the error variant.

Network state transitions

The state diagram shows which transitions are valid, and the discriminated union enforces which data is available in each state.

Exhaustiveness Checking

When a discriminated union grows, you want the compiler to flag any variant a switch statement forgot to handle. The never type makes this automatic, since a value can only be never once every real case has already been handled.

typescripttypescript
function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${value}`);
}

Call this helper from a default case. As long as every real case returns early, the remaining value should already be narrowed to never by the time execution reaches the default branch:

typescripttypescript
function render(state: NetworkState): string {
  switch (state.state) {
    case "idle": return "Ready.";
    case "loading": return "Loading...";
    case "success": return `Loaded ${state.data.length} items.`;
    case "error": return `Error: ${state.message}`;
    default: return assertNever(state);
  }
}

The payoff shows up later, when someone adds a new variant to the union without remembering to update every function that switches on it. Imagine a teammate extends the type with a new "cached" state for offline data:

typescripttypescript
type NetworkState =
  | { state: "idle" }
  | { state: "loading" }
  | { state: "success"; data: string[] }
  | { state: "error"; message: string }
  | { state: "cached"; retrievedAt: Date };

The new cached variant is no longer covered by any case, so it still reaches the default branch. Since that value is not actually never, the call to the assertNever helper now fails to compile:

texttext
Argument of type '{ state: "cached"; retrievedAt: Date; }' is not assignable to parameter of type 'never'.

The error points directly at the unhandled variant. Adding a matching cached case makes the error disappear. This is exhaustiveness checking: the compiler guarantees every variant gets handled, even as the union grows over time.

Choosing the Discriminant Name

Any shared literal property works as a discriminant. Common choices:

Discriminant nameUse case
kindGeneral-purpose variant labeling
typeAction types, event types, message types
status / stateUI states, process states
tagFP-style tagged unions

The important rule: the discriminant value must be a unique literal type per variant. If two variants share the same discriminant value, narrowing cannot distinguish them.

typescripttypescript
// Bad: both have kind "shape"
type Bad = 
  | { kind: "shape"; radius: number }
  | { kind: "shape"; sideLength: number };

Multiple Discriminants

A discriminated union is not limited to one discriminant property. You can narrow on the main discriminant first, then check a second property once you know which variant you have:

typescripttypescript
type Event =
  | { type: "click"; target: string }
  | { type: "keypress"; key: string; modifier: boolean }
  | { type: "scroll"; position: number };
 
function handle(event: Event) {
  if (event.type === "keypress" && event.modifier) {
    console.log(`Modified key: ${event.key}`);
  }
}

TypeScript narrows correctly through both checks, as long as the discriminant pattern is clear at each step.

Common Mistakes

Using a string instead of a string literal for the discriminant. If the discriminant is typed as a plain string, narrowing does not work because any string value is possible.

typescripttypescript
interface Loose {
  kind: string;   // Too broad -- narrowing won't work
  radius: number;
}

Always use a literal value for the discriminant field, not a broad string type.

Forgetting to handle every variant. Without a never-based exhaustiveness check, new variants silently fall through to the default case. Always add the assertNever pattern shown earlier.

Making every property optional instead of splitting into variants. The whole point of discriminated unions is to give each variant its own required fields. A single type with optional everything loses type safety.

Overusing discriminated unions for simple cases. If a value only has two variants that share most properties, a plain union with narrowing might be simpler. Use discriminated unions when each variant has meaningfully different data.

For more on the narrowing techniques that power discriminated unions, see type narrowing with typeof and custom type guards. The discriminated union pattern depends on the same narrowing engine.

Rune AI

Rune AI

Key Insights

  • A discriminated union has a shared literal property (the discriminant) on every member.
  • Checking the discriminant tells TypeScript exactly which member you have, unlocking its specific fields.
  • Use switch statements on the discriminant for clean, exhaustive narrowing.
  • Add a never-based default case to catch unhandled variants at compile time.
  • The pattern works for network states, form steps, UI states, and any multi-variant data.
RunePowered by Rune AI

Frequently Asked Questions

What is a discriminated union in TypeScript?

A discriminated union is a union of object types where each member has a common property with a unique literal type. TypeScript uses that property (the discriminant) to narrow which specific member you are working with when you check its value.

What is the discriminant property?

The discriminant is the shared property across all members of a union that has a unique literal value for each member. Common names are kind, type, status, or state. It is the property you switch on to narrow the union.

How do I make sure I handle every variant of a discriminated union?

Use exhaustiveness checking. Add a default case that assigns the value to a variable of type never. If you forget a variant, TypeScript produces a compile error because the remaining type is not assignable to never.

Conclusion

Discriminated unions turn a union of similar-looking objects into a safe, exhaustively-checked type. Give each member a unique literal discriminant, switch on it, and let TypeScript narrow for you. Add a never check in the default case to catch missed variants when the union grows.