Real-world values are often "one of several things": an ID might be a number or a string; an API response is either a success payload or an error. TypeScript models this with union types, combines shapes with intersection types, and lets you safely work with unions through type narrowing. This trio is the heart of idiomatic TypeScript.
A union type uses | to say a value may be any one of several types:
type Id = number | string;
function printId(id: Id) {
console.log("ID: " + id);
}
printId(101); // OK
printId("abc-7"); // OK
printId(true); // ErrorThe catch: inside printId, you may only use operations valid for every member of the union. id.toUpperCase() fails because numbers have no such method. To use type-specific operations, you must narrow.
TypeScript performs control-flow analysis: ordinary JavaScript checks refine the type inside each branch.
function format(id: number | string): string {
if (typeof id === "string") {
return id.toUpperCase(); // id is string here
}
return id.toFixed(0); // id is number here
}
function describe(value: Date | string[]) {
if (value instanceof Date) return value.toISOString();
return value.join(", ");
}
interface Car { wheels: number }
interface Boat { sails: number }
function count(v: Car | Boat) {
return "wheels" in v ? v.wheels : v.sails; // narrowed by property check
}Truthiness checks and equality comparisons narrow too — if (name) removes null and undefined from string | null | undefined.
The most powerful pattern gives each union member a common literal property — the discriminant — and switches on it:
interface Circle { kind: "circle"; radius: number }
interface Square { kind: "square"; side: number }
type Shape = Circle | Square;
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.side ** 2;
}
}Each case narrows shape to exactly one member. Add an exhaustiveness check with never and the compiler will flag any new shape you forget to handle:
default: {
const _exhaustive: never = shape; // errors if a case is missing
return _exhaustive;
}This pattern is everywhere: Redux actions, API result types, state machines.
While unions mean "either/or", intersections (&) mean "both at once" — the resulting type has all members of every part:
type Timestamped = { createdAt: Date };
type Authored = { author: string };
type Post = Timestamped & Authored & { title: string };
const post: Post = {
title: "Narrowing in TypeScript",
author: "Ada",
createdAt: new Date(),
};Intersections shine for mixing in cross-cutting concerns. Beware of intersecting incompatible primitives — string & number collapses to never, a type with no possible values.
When built-in checks are not enough, write a function whose return type is a type predicate:
function isCircle(shape: Shape): shape is Circle {
return shape.kind === "circle";
}
if (isCircle(myShape)) {
console.log(myShape.radius); // narrowed to Circle
}Type guards let you centralize validation logic — perfect for checking parsed JSON against an expected shape.
A | B means either type; you can only use members common to both until you narrow.typeof, instanceof, in, truthiness, and equality checks all narrow types automatically.switch are the idiomatic way to model variant data, with never for exhaustiveness.A & B combines shapes; incompatible intersections collapse to never.x is T) encapsulate reusable narrowing logic.Next lesson: Arrays, Tuples and readonly — typed collections and fixed-shape lists.