100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
TypeScript

Discriminated Unions in TypeScript

Use a shared literal 'tag' property across union members so TypeScript can narrow exactly which member you're handling in a switch or if.

Advanced TypesAdvanced14 min readJul 8, 2026
Analogies

1. Introduction

A discriminated union (also called a tagged union or algebraic data type) is a union of object types that all share a common property — the discriminant, or tag — whose type is a distinct literal for each member. Because the tag's literal value uniquely identifies which member of the union you're dealing with, TypeScript's control-flow analysis can narrow the union to the exact matching interface inside a switch on the tag or an if that checks it, giving you full IntelliSense and type safety for the narrowed member's other properties without any type assertions.

🏏

Cricket analogy: A scorecard's dismissal type field ('bowled', 'caught', 'run out') tells you exactly which follow-up details apply, just as a discriminant like kind: "circle" lets TypeScript narrow a union to the exact matching shape.

2. Syntax

typescript
interface Circle {
  kind: "circle";   // literal discriminant
  radius: number;
}
interface Square {
  kind: "square";
  side: number;
}

type Shape = Circle | Square;

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

3. Explanation

The discriminant property (kind in the example) must be typed as a distinct literal type on every member of the union — "circle" on Circle, "square" on Square, and so on — not as the wider string. When you switch or branch on shape.kind, TypeScript's narrowing algorithm intersects the checked literal with each union member's declared literal for that property; only the member(s) whose kind literal matches remain, so inside case "circle": the compiler knows shape is exactly Circle and grants access to radius without a cast.

🏏

Cricket analogy: Just as an umpire's dismissal call must be one exact literal like 'lbw' rather than a vague 'out', the kind field on Circle must be the literal "circle", not the wider string, for narrowing to work in a switch.

This pattern scales cleanly to any number of union members and is the idiomatic TypeScript equivalent of algebraic data types / pattern matching found in functional languages. It is heavily used for representing API responses ({ status: "success", data } | { status: "error", message }), Redux-style actions ({ type: "INCREMENT" } | { type: "SET", value: number }), and state machines.

🏏

Cricket analogy: Just as a dismissal-type field scales to cover every possible way a batsman gets out across formats, a discriminated union scales to any number of members, powering things like ball-by-ball commentary state machines.

Adding a default case that assigns the still-unhandled value to a variable typed never (const _exhaustive: never = shape;) turns a missing case into a compile-time error: if a new member is added to the union later and a case is forgotten, TypeScript will report that the new member's type isn't assignable to never, catching the omission before deployment.

Gotcha: if the discriminant property is typed as string instead of a specific literal (e.g. kind: string rather than kind: "circle"), TypeScript cannot narrow on it at all, because string doesn't distinguish members. This most often happens accidentally when the interface is inferred from a non-const value, or when someone widens the type while refactoring.

4. Example

typescript
interface Circle {
  kind: "circle";
  radius: number;
}
interface Square {
  kind: "square";
  side: number;
}
interface Rectangle {
  kind: "rectangle";
  width: number;
  height: number;
}

type Shape = Circle | Square | Rectangle;

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:
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

const shapes: Shape[] = [
  { kind: "circle", radius: 2 },
  { kind: "square", side: 3 },
  { kind: "rectangle", width: 4, height: 5 },
];

shapes.forEach((s) => console.log(s.kind, area(s).toFixed(2)));

5. Output

text
circle 12.57
square 9.00
rectangle 20.00

6. Key Takeaways

  • A discriminated union is a union of object types sharing a common literal-typed 'tag' property.
  • Switching or branching on the tag lets TypeScript narrow to the exact matching interface, with no casts needed.
  • The tag property must be a distinct literal type per member, not a wide string, for narrowing to work.
  • A never-typed exhaustiveness check in the default/else branch catches unhandled union members at compile time.
  • This pattern is TypeScript's idiomatic equivalent of algebraic data types / pattern matching.

Practice what you learned

Was this page helpful?

Topics covered

#TypeScript#TypeScriptProgrammingStudyNotes#Programming#DiscriminatedUnionsInTypeScript#Discriminated#Unions#Syntax#Explanation#StudyNotes#SkillVeris