Last 30 Days
No notifications
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.
A BA 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.
A & BA 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, updatedAtUseful for mixing in common fields.
type WithLoading<T> = T & { loading: boolean };
type LoadableUser = WithLoading<User>;nevertype Bad = { x: string } & { x: number };
declare const b: Bad;
b.x; // never — impossible to constructEach 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.
neverAdd 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.
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"); // ❌
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.
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.
object unionsWithout 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.
A | B — *one of*. Narrow before using member-specific props.A & B — *all of*. Great for mixing in shared fields.never in the default branch for exhaustive switches.