RizTech Academy logo
RizTech Academy
Design Patterns in TypeScriptLesson 2 of 530 min

Discriminated unions instead of class hierarchies

The object-oriented instinct, when you have "several kinds of a thing", is a class hierarchy: a base class and a subclass per kind, with polymorphic methods. It is the shape the State, Strategy, and Visitor patterns take in Java. In TypeScript, a discriminated union is very often better — clearer, safer, and without the ceremony. This lesson shows the trade, so you reach for the right one.

The class-hierarchy approach

Here is "a shape is a circle, rectangle, or triangle" as a class hierarchy — the classic OO modelling:

abstract class Shape {
  abstract area(): number;
}

class Circle extends Shape {
  constructor(private radius: number) { super(); }
  area(): number { return Math.PI * this.radius ** 2; }
}

class Rectangle extends Shape {
  constructor(private width: number, private height: number) { super(); }
  area(): number { return this.width * this.height; }
}

Each shape is a class, each implements area(). To compute an area you call shape.area(), and polymorphism dispatches to the right one. This works, and for some designs it is right. But notice what it costs and constrains.

The discriminated-union approach

The same domain as a discriminated union (from the unions module):

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

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

Same result, and compare the two on several axes:

Data and behaviour are separate. The union describes the data; area is a plain function operating on it. The hierarchy bundles the area logic into each class. Separating them means you can add a new operation (perimeter, describe, toSvg) as a new function without touching the shape definitions — where the hierarchy would require adding a method to every class. For data with many operations, the union wins.

Exhaustiveness is checked. With the union, the never exhaustiveness pattern makes the compiler prove you handled every kind — add a shape and every switch breaks until you handle it. A class hierarchy has no equivalent: forget to handle a new subclass somewhere and it silently does the wrong thing. This compile-time guarantee is a major advantage of unions.

No inheritance ceremony. The union is data — no abstract, no extends, no super, no constructors. It is lighter to write and read.

Serialisation is natural. A union value is a plain object — it serialises to JSON and back trivially, which matters constantly (API payloads, storage, state). A class instance needs reconstruction (a plain JSON object is not a Circle instance — it lost its methods and prototype). For data that crosses a network or storage boundary, unions are far more natural.

When the class hierarchy is still right

Be fair — hierarchies are not obsolete:

  • When each kind has substantial behaviour and state, not just data — if a Circle has methods, private state, and a lifecycle, a class is the natural home.
  • When a framework requires classes — Angular components, NestJS providers.
  • When you frequently add new kinds but rarely new operations — a hierarchy lets a new subclass be self-contained (it implements the known methods), where a union would require updating every function. (This is the classic "expression problem" trade: hierarchies make adding types easy; unions make adding operations easy.)

The trade-off to remember: a discriminated union makes it easy to add new operations (functions) and hard to add new kinds (touch every function); a class hierarchy makes it easy to add new kinds (subclasses) and hard to add new operations (touch every class). Choose based on which you expect to do more — and for most application data (which has few kinds and many operations, and crosses serialisation boundaries), the union wins.

The patterns this replaces

Several Gang-of-Four patterns become a discriminated union in TypeScript:

  • State — "an object behaves differently by state" is a union of states + a function that switches on the discriminant (the async RequestState, a player's states). No State-object hierarchy needed.
  • Strategy (the data-shaped kind) — when strategies are just "which variant am I", a union handles it; when they are pure behaviour, a function (next lesson's territory) handles it.
  • Visitor — the Visitor pattern exists in OO to add operations to a hierarchy without modifying it; a union sidesteps the whole problem, because adding an operation is just writing a new function over the union. The elaborate Visitor machinery is unnecessary.

When you see these patterns in a TypeScript codebase implemented as class hierarchies, ask whether a discriminated union would be simpler — it very often would.

Check your work

The OO instinct for "several kinds". A class hierarchy — a base class and a subclass per kind with polymorphic methods.

The TypeScript alternative. A discriminated union describing the data, with plain functions that switch on the discriminant.

Data-and-behaviour separation. The union separates data (the type) from behaviour (functions), so adding an operation is a new function, not a method on every class.

Exhaustiveness. The union's never pattern makes the compiler prove every kind is handled; a hierarchy has no equivalent.

Serialisation. A union value is a plain object (trivial JSON); a class instance needs reconstruction — unions win at boundaries.

When a hierarchy is still right. Substantial per-kind behaviour and state, a framework requiring classes, or frequently adding new kinds but rarely new operations.

The expression-problem trade. Unions make adding operations easy and kinds hard; hierarchies make adding kinds easy and operations hard — choose by which you do more.

Which patterns a union replaces. State, data-shaped Strategy, and Visitor.

Practice

  1. Model a Shape as a class hierarchy and as a discriminated union. Compute area in both.
  2. Add a new operation (perimeter) to both. Note the union needs one new function; the hierarchy needs a method on every class.
  3. Add a new kind (triangle) to both. Note the hierarchy adds a self-contained subclass; the union requires updating every function — and the exhaustiveness check leads you to each.
  4. Serialise a union value to JSON and back, and confirm it round-trips. Try the same with a class instance and note it loses its methods.
  5. Add exhaustiveness checking to the union's area and confirm a missing kind is a compile error.
  6. Take a State or Visitor pattern (or a class hierarchy) you know and rewrite it as a discriminated union. Decide whether it is clearer.
  7. For a domain you know, decide whether you add kinds or operations more often, and pick union or hierarchy accordingly.

Official documentation

Next: factory and builder with types and generics.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship