IntermediateTypeScript · Lesson 1 of 9

Union Types & Narrowing

Values that can be one of several types, and how TypeScript narrows them safely.

A union type A | B means a value can be either. Literal types restrict a value to exact options: "pending" | "paid" | "failed" is far safer than a plain string.

Before using a union, you narrow it: checks like typeof, in, instanceof or comparing a shared field tell TypeScript which member you have inside each branch.

A discriminated union gives every member a common literal field (often kind or type). Switching on that field narrows perfectly, and assigning the leftover case to never makes the compiler warn you if a new case is added but not handled.

payments.tsTypeScript
type Payment =
  | { kind: "mpesa"; phone: string; amount: number }
  | { kind: "card"; last4: string; amount: number }
  | { kind: "cash"; amount: number };

function describe(p: Payment): string {
  switch (p.kind) {
    case "mpesa":
      return "M-Pesa from " + p.phone + ": " + p.amount + " TSh";
    case "card":
      return "Card ****" + p.last4 + ": " + p.amount + " TSh";
    case "cash":
      return "Cash: " + p.amount + " TSh";
    default: {
      const unhandled: never = p;
      return unhandled;
    }
  }
}

function formatId(id: string | number): string {
  return typeof id === "number" ? id.toString().padStart(6, "0") : id.toUpperCase();
}

console.log(describe({ kind: "mpesa", phone: "0712 345 678", amount: 15000 }));
console.log(formatId(42), formatId("inv-7"));

Key points

  • Prefer literal unions over plain strings for fixed sets of values.
  • Narrow with typeof, in, instanceof or a discriminant field.
  • The never check turns a forgotten case into a compile error.

Exercise

Model a Shape union of circle, rectangle and triangle (each with its own fields) and write area(shape). Add a fourth shape and confirm the compiler points to the switch that needs updating.

Show solution

Try the exercise yourself first — then compare your approach with this one.

Model the shapes as a discriminated union on kind. The switch narrows each case, and the never check in default means that adding a fourth shape (for example a square) makes the compiler point at this function until you handle it.

shapes.tsTypeScript
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 0.5 * shape.base * shape.height;
    default: {
      const unhandled: never = shape;
      throw new Error("Unknown shape: " + JSON.stringify(unhandled));
    }
  }
}

const shapes: Shape[] = [
  { kind: "circle", radius: 1 },
  { kind: "rectangle", width: 3, height: 4 },
  { kind: "triangle", base: 6, height: 2 },
];

for (const s of shapes) console.log(s.kind, area(s).toFixed(2));
// circle 3.14 / rectangle 12.00 / triangle 6.00

Check your understanding

  1. What does the type "pending" | "paid" | "failed" allow?

  2. In a discriminated union, what is the discriminant?

  3. Why assign the leftover case to a variable of type never?

  4. Inside if (typeof id === "number"), what is the type of id declared as string | number?

Ask AI