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 })); // 24In 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