Notifications

No notifications

/Phase 2

Union & Intersection Types

Unions (A | B) say *one of these*. Intersections (A & B) say *all of these*. Together they let you model real-world data that almost never fits one perfect shape — payment methods, API responses, form fields. Mastering discriminated unions is the single biggest level-up after primitives.

On this page

Detailed Theory

Union types — A
B

A value is one of the listed types.

type ID = string
number;

let id: ID = 1; id = "abc"; // ✅ id = true; // ❌

You can only access properties that exist on every member:

function pad(x: string | number) {
  // x.toUpperCase();      // ❌ — only string has it
  console.log(typeof x);  // ✅ both have typeof
}

That's why narrowing matters (next topic). For now, the safe operations are the intersection of all members.

Intersection types — A & B

A value satisfies *all* of the listed types.

type Timestamps = { createdAt: Date; updatedAt: Date };
type User = { id: number; name: string };
type StoredUser = User & Timestamps;
// must have id, name, createdAt, updatedAt

Useful for mixing in common fields.

type WithLoading<T> = T & { loading: boolean };
type LoadableUser = WithLoading<User>;

Conflicting intersections collapse to never

type Bad = { x: string } & { x: number };
declare const b: Bad;
b.x; // never — impossible to construct

Discriminated (tagged) unions — the killer feature

Each member of the union carries a literal tag (often called kind, type, or status). The tag tells TS which member you're in.

type Loading = { status: "loading" };
type Success<T> = { status: "success"; data: T };
type Failure   = { status: "error"; error: string };

type Result<T> = Loading

Success<T>
Failure;

function render<T>(r: Result<T>) { switch (r.status) { case "loading": return "⏳"; case "success": return r.data; // narrowed to Success case "error": return r.error; // narrowed to Failure } }

This is how Redux, Zustand reducers, GraphQL union responses, and React's useReducer actions are typed.

Exhaustive checks with never

Add a default case that assigns to never — TS will error if you ever forget to handle a new variant.

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.r ** 2;
    case "square": return s.s ** 2;
    default: {
      const _exhaustive: never = s;
      return _exhaustive;
    }
  }
}

When you add { kind: "triangle" } to Shape later, the default branch becomes a compile error pointing right at the file you forgot. Compiler-driven refactors at their best.

Union of literal types

The simplest, most useful unions are string literal unions — autocomplete-friendly enums-without-the-runtime-cost.

type Direction = "up" 
"down""left"
"right"; type HttpMethod = "GET"
"POST""PUT"
"DELETE";

function move(d: Direction) { /* … */ } move("up"); // ✅ // move("diagonal"); // ❌

Combining unions with intersections

type ApiResponse =
  | { ok: true; data: unknown }
  | { ok: false; error: string };

type WithMeta<T> = T & { requestId: string }; type ApiResp = WithMeta<ApiResponse>;

ApiResp is still a union — requestId gets distributed to every variant.

Distributive conditional types — preview

Conditional types over a union *distribute* over each member:

type ToArray<T> = T extends any ? T[] : never;
type R = ToArray<string 
number>; // string[]
number[]

You'll see this everywhere later when we hit utility types.

Common mistake — naked object unions

Without a discriminant, TS can't tell you which member you have:

type Cat = { meow(): void; lives: number };
type Dog = { bark(): void; breed: string };

function speak(p: Cat | Dog) { if ("meow" in p) p.meow(); // narrows by structure else p.bark(); }

Works, but adding a kind field (discriminated union) is faster, clearer, and survives renames.

TL;DR

  • A | B — *one of*. Narrow before using member-specific props.
  • A & B — *all of*. Great for mixing in shared fields.
  • Always discriminate unions of objects with a literal tag.
  • Use never in the default branch for exhaustive switches.