Skip to content
Laviyx Learn

Try

TypeScript course menu

TypeScript Discriminated Unions: Modelling States Safely

Learn discriminated (tagged) unions in TypeScript: a shared literal property that identifies each member, narrowing with if and switch, exhaustive checks with never, modelling loading states, API results and actions, and avoiding impossible states.

Intermediate10 min readUpdated
On this page

A discriminated union (or tagged union) is a union of object types that all share one property, the discriminant, whose value is a different literal in each member. By checking that property, TypeScript knows exactly which member you have. It is the standard way to model things that can be in one of several states.

The pattern#

TypeScript
type Circle = { kind: "circle"; radius: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Triangle = { kind: "triangle"; base: number; height: number };

type Shape = Circle | Rectangle | Triangle;

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;        // shape: Circle
    case "rectangle":
      return shape.width * shape.height;         // shape: Rectangle
    case "triangle":
      return (shape.base * shape.height) / 2;    // shape: Triangle
  }
}

const shapes: Shape[] = [
  { kind: "circle", radius: 1 },
  { kind: "rectangle", width: 3, height: 4 },
  { kind: "triangle", base: 6, height: 2 },
];
console.log(shapes.map((s) => area(s).toFixed(2)));
Output
[ '3.14', '12.00', '6.00' ]
  1. Every member has a kind property with a unique literal value.
  2. Checking shape.kind narrows shape to one member, so only that member's properties are available.

Using a property that belongs to another member is an error:

TypeScript
type Circle = { kind: "circle"; radius: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Shape = Circle | Rectangle;

function describe(shape: Shape): string {
  if (shape.kind === "circle") {
    return `Width ${shape.width}`;
  }
  return "rectangle";
}
Compiler error
error TS2339: Property 'width' does not exist on type 'Circle'.

Exhaustiveness checking#

Add a default branch that assigns the value to never. If someone adds a new shape and forgets to handle it, the code stops compiling:

TypeScript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "hexagon"; side: 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 unhandled: never = shape;
      throw new Error(`Unknown shape: ${JSON.stringify(unhandled)}`);
    }
  }
}
Compiler error
error TS2322: Type '{ kind: "hexagon"; side: number; }' is not assignable to type 'never'.

The error names the forgotten member. This is one of the most valuable safety nets in TypeScript for growing codebases (never).

Modelling loading states#

A classic beginner model for loading data uses separate booleans and optional fields:

TypeScript
type BadState = {
  isLoading: boolean;
  error?: string;
  data?: string[];
};

It allows impossible states: isLoading: true with an error and data at the same time. A discriminated union allows only the states that make sense:

TypeScript
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; message: string };

function render(state: RequestState): string {
  switch (state.status) {
    case "idle":
      return "Press the button to load courses";
    case "loading":
      return "Loading...";
    case "success":
      return `Loaded: ${state.data.join(", ")}`;
    case "error":
      return `Something went wrong: ${state.message}`;
  }
}

const states: RequestState[] = [
  { status: "idle" },
  { status: "loading" },
  { status: "success", data: ["HTML", "CSS"] },
  { status: "error", message: "Network timeout" },
];
for (const s of states) console.log(render(s));
Output
Press the button to load courses
Loading...
Loaded: HTML, CSS
Something went wrong: Network timeout

data only exists in the success state, so you cannot accidentally read it while loading.

Results instead of exceptions#

The same idea models operations that can succeed or fail:

TypeScript
type Result<T> = { ok: true; value: T } | { ok: false; error: string };

function parsePercent(input: string): Result<number> {
  const n = Number(input);
  if (Number.isNaN(n)) return { ok: false, error: `"${input}" is not a number` };
  if (n < 0 || n > 100) return { ok: false, error: `${n} is out of range` };
  return { ok: true, value: n };
}

for (const input of ["42", "abc", "150"]) {
  const result = parsePercent(input);
  console.log(result.ok ? `value ${result.value}` : `error: ${result.error}`);
}
Output
value 42
error: "abc" is not a number
error: 150 is out of range

Here the discriminant is the boolean literal ok. Callers cannot use value without checking ok first.

Where you will see this pattern#

  • Redux and useReducer actions: { type: "add"; item: Item } | { type: "remove"; id: string }.
  • API responses with a status or type field.
  • Events and messages in WebSocket or worker communication.
  • Payment methods, notification types, form steps.

Practice#

Practice exercise

Model cart actions as a discriminated union: add (with name and price), remove (with name) and clear. Write reduce(cart: { name: string; price: number }[], action: CartAction) that returns a new cart for each action, with an exhaustive switch. Apply add, add, remove and print the final cart total.

Show solution
TypeScript
type Item = { name: string; price: number };
type CartAction =
  | { type: "add"; name: string; price: number }
  | { type: "remove"; name: string }
  | { type: "clear" };

function reduce(cart: Item[], action: CartAction): Item[] {
  switch (action.type) {
    case "add":
      return [...cart, { name: action.name, price: action.price }];
    case "remove":
      return cart.filter((item) => item.name !== action.name);
    case "clear":
      return [];
    default: {
      const unhandled: never = action;
      return unhandled;
    }
  }
}

const actions: CartAction[] = [
  { type: "add", name: "Pen", price: 20 },
  { type: "add", name: "Notebook", price: 60 },
  { type: "remove", name: "Pen" },
];

const cart = actions.reduce(reduce, []);
console.log(cart, cart.reduce((sum, i) => sum + i.price, 0));
Output
[ { name: 'Notebook', price: 60 } ] 60

Key takeaway#

Finished this lesson?