TypeScript by Example

Union Types

Union (|), intersection (&), literal unions, and discriminated unions - the building blocks of TypeScript's variant-safe modeling.

A union type expresses "this value is one of these types." TypeScript narrows the union in each branch so every case is handled with the exact right shape.

The | operator creates a union. TypeScript enforces that you handle every member before using member-specific properties.

type StringOrNumber = string | number;
 
function format(value: StringOrNumber): string {
  if (typeof value === "string") {
    return value.toUpperCase(); // string methods available here
  }
  return value.toFixed(2); // number methods available here
}
 
console.log(format("hello")); // "HELLO"
console.log(format(3.14159)); // "3.14"

The & operator creates an intersection - a type that satisfies all members simultaneously. Use intersections to combine two shapes into one without inheritance.

type Named = { name: string };
type Aged = { age: number };
type Person = Named & Aged;
 
const person: Person = { name: "Ada", age: 36 };
 
type WithTimestamp<T> = T & { createdAt: Date };
type TimestampedPerson = WithTimestamp<Person>;
 
const record: TimestampedPerson = {
  name: "Grace",
  age: 85,
  createdAt: new Date("1906-12-09"),
};

String literal unions model a closed set of allowed values - safer than string and more ergonomic than an enum. TypeScript checks exhaustiveness and provides autocomplete.

type Status = "pending" | "active" | "inactive" | "deleted";
type Direction = "north" | "south" | "east" | "west";
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
 
function handleStatus(status: Status): string {
  if (status === "active") return "User is active";
  if (status === "deleted") return "User was deleted";
  return `Status: ${status}`;
}
 
// TypeScript error - "banned" is not assignable to type Status
// handleStatus("banned");

A discriminated union adds a shared literal field (the discriminant) that TypeScript uses to narrow the active variant in a switch. Each branch gets the exact shape for that case - no optional fields, no casting.

type Shape =
  | { kind: "circle";    radius: number }
  | { kind: "rect";      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 "rect":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
  }
}
 
console.log(area({ kind: "circle", radius: 5 })); // 78.53981633974483
console.log(area({ kind: "rect", width: 4, height: 6 })); // 24

In production

String literal unions are safer than enum for flag and variant types in most production codebases. They serialize to the same string in JSON without a compilation artifact, don't carry the numeric-enum falsy footgun (where 0 is falsy and if (status) silently skips the default state), and produce cleaner error messages when a value is wrong. Discriminated unions replace most class hierarchies: values serialize naturally to JSON, work with Redux and XState without adapters, and make adding new variants safe - as long as you include an exhaustiveness check (default: const _: never = shape) in every switch.

Enjoyed this? Get more essays on software craft delivered to your inbox.

Subscribe free