Union Types, Intersection Types and Type Narrowing

intermediate
12 min

Union Types, Intersection Types and Type Narrowing

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.

Union Types

A union type uses | to say a value may be any one of several types:

typescript
type Id = number | string; function printId(id: Id) { console.log("ID: " + id); } printId(101); // OK printId("abc-7"); // OK printId(true); // Error

The 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.

Narrowing with typeof, instanceof, and in

TypeScript performs control-flow analysis: ordinary JavaScript checks refine the type inside each branch.

typescript
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.

Discriminated Unions

The most powerful pattern gives each union member a common literal property — the discriminant — and switches on it:

typescript
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:

typescript
default: { const _exhaustive: never = shape; // errors if a case is missing return _exhaustive; }

This pattern is everywhere: Redux actions, API result types, state machines.

Intersection Types

While unions mean "either/or", intersections (&) mean "both at once" — the resulting type has all members of every part:

typescript
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.

Custom Type Guards

When built-in checks are not enough, write a function whose return type is a type predicate:

typescript
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.

Key Takeaways

  • 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.
  • Discriminated unions plus switch are the idiomatic way to model variant data, with never for exhaustiveness.
  • A & B combines shapes; incompatible intersections collapse to never.
  • Custom type guards (x is T) encapsulate reusable narrowing logic.

Next lesson: Arrays, Tuples and readonly — typed collections and fixed-shape lists.

Union Types, Intersection Types and Type Narrowing - TypeScript | CodeYourCraft | CodeYourCraft