Unions & Intersections
Union: a value that is one of several types
Section titled “Union: a value that is one of several types”A union A | B describes a value that is either an A or a B. In the sets model it’s the set union — so a union is always wider than its members.
type Id = string | number;
let id: Id = "abc";id = 123; // ✅ both are members of the unionThe catch: while you hold a union, you may only touch the members that all branches share, because the compiler can’t know which one you have yet.
// @expect-error TS2339function printId(id: string | number) { id.toUpperCase(); // ❌ toUpperCase does not exist on number. // Only members common to string AND number are available here.}To use type-specific members, you must narrow — the whole next lesson. Until then, a union only exposes the common surface.
Intersection: a value that is all of several types
Section titled “Intersection: a value that is all of several types”An intersection A & B describes a value that is both an A and a B at once. For object types this means “has every property of both” — so an intersection of objects is narrower (more requirements, fewer valid values).
type WithId = { id: string };type WithTimestamps = { createdAt: Date; updatedAt: Date };
type Entity = WithId & WithTimestamps;// must have id AND createdAt AND updatedAt
const e: Entity = { id: "1", createdAt: new Date(), updatedAt: new Date() };This is the idiomatic way to compose object types from small reusable pieces — far more flexible than inheritance because you can mix any combination.
They pull in opposite directions
Section titled “They pull in opposite directions”flowchart TB
subgraph un["A OR B (union)"]
u["more values allowed
fewer members guaranteed"]
end
subgraph inter["A AND B (intersection)"]
i["fewer values allowed
more members guaranteed"]
end Notice the trade in each direction:
- A union allows more values but guarantees fewer members you can safely access (only the common ones).
- An intersection allows fewer values but guarantees more members (all of them).
The edge cases fall out of the sets model
Section titled “The edge cases fall out of the sets model”string & numberisnever— no value is both, so the intersection is the empty set.- A union with
anycollapses toany; a union withneverdrops thenever(T | neveris justT, because adding the empty set changes nothing). - Intersecting object types with a conflicting primitive property can produce
neverfor that property.
type A = { kind: "a"; value: number };type B = { kind: "b"; value: string };type Both = A & B;// kind: "a" & "b" → never; this type is effectively unconstructableThat last one is a hint: when you want “one of these object shapes,” you almost always want a union (A | B) with a shared discriminant field — not an intersection. That’s the discriminated-union pattern in the narrowing lesson.
Weak types: a check that survives variable indirection
Section titled “Weak types: a check that survives variable indirection”Object literals get an excess-property check, but only when assigned fresh — through a variable, extra properties are normally allowed (the objects lesson covers this). Unions of object types made entirely of optional properties get a stricter check that isn’t fooled by indirection: if the value shares no property at all with any union member, it’s rejected even through a variable:
// @expect-error TS2345interface Circle { radius?: number }interface Square { side?: number }
function paint(shape: Circle | Square) {}
const opts = { raduis: 10 }; // typo -- not a fresh literal, assigned to a variable firstpaint(opts); // ❌ still rejected -- 'opts' shares no property with Circle or SquareThis “weak type” rule exists specifically because a union of all-optional interfaces would otherwise accept almost anything — every object is vacuously compatible with { radius?: number } since it has no required properties. Requiring at least one shared property closes that hole, and unlike the ordinary excess-property check, it isn’t limited to fresh literals.
Intersecting function types: combined call signatures
Section titled “Intersecting function types: combined call signatures”& on two function types doesn’t average them — it produces a value that’s callable as either signature, which is how you build an overloaded function value out of two plain ones:
// @runtype StrFn = (x: string) => string;type NumFn = (x: number) => number;type Overloaded = StrFn & NumFn;
function impl(x: string): string;function impl(x: number): number;function impl(x: string | number): string | number { return typeof x === "string" ? x.toUpperCase() : x * 2;}
const fn: Overloaded = impl as Overloaded; // one runtime function, two call signaturesconsole.log(fn("hi")); // ✅ matches StrFnconsole.log(fn(5)); // ✅ matches NumFn// HI// 10A union of the same two function types is far more restrictive: you can only call it with arguments valid for every member, because the compiler doesn’t know at the call site which one it actually is.
// @expect-error TS2554type Logger = (msg: string) => void;type Reporter = (msg: string, code: number) => void;declare const handler: Logger | Reporter;handler("hi"); // ❌ Expected 2 arguments, but got 1 -- Reporter's signature also has to be satisfiedThis is the same widen/narrow tension from the rest of the module, just applied to callables: intersecting functions widens what you can call them with (more signatures to match), while a union of functions narrows it (only the common ground is safe).
Intersecting vs unioning function types
type StrFn = (x: string) => string;type NumFn = (x: number) => number;type Overloaded = StrFn & NumFn; // callable as either signature
function impl(x: string): string;function impl(x: number): number;function impl(x: string | number): string | number { return typeof x === "string" ? x.toUpperCase() : x * 2;}
const fn: Overloaded = impl as Overloaded; // one runtime function, two call signaturesconsole.log(fn("hi")); // matches StrFnconsole.log(fn(5)); // matches NumFn A display note for TypeScript 7
Section titled “A display note for TypeScript 7”TypeScript 7 orders the members of a union deterministically (--stableTypeOrdering, permanently on), so a hover or error message may show string | number where 6.0 showed number | string. Nothing about assignability changes — unions are sets — but tests that snapshot error text or .d.ts output may need regenerating.
Mental Model
Section titled “Mental Model”flowchart LR
A["Type A
(a set of values)"] --- OR{{"A | B"}}
B["Type B
(a set of values)"] --- OR
OR --> wider["Wider set
fewer guaranteed members"]
A --- AND{{"A & B"}}
B --- AND
AND --> narrower["Narrower set
more guaranteed members"] Keep asking one question at every | or &: which set of values am I describing, and which members am I guaranteed to be able to touch without narrowing? A union enlarges the set of valid values but shrinks the safely-accessible surface to the common members; an intersection shrinks the set of valid values but grows the guaranteed surface to every member of every part. The “weak type” check and the function-signature behavior above are both just this same rule applied to a less obvious shape — a union of optional-property objects, a union of callables.
Trade-offs
Section titled “Trade-offs”| Approach | Benefit | Cost |
|---|---|---|
Union (A | B) | Models “one of several shapes,” matches real-world variance in data | Only common members are accessible until you narrow |
Intersection (A & B) | Composes small, reusable object pieces without inheritance | For conflicting primitive properties, the intersection collapses to never |
| Intersection of function types | Builds one overloaded value from two signatures | The runtime implementation must genuinely handle every signature — the type only promises it, it doesn’t provide it |
| Union of all-optional interfaces | Convenient when every field really is optional in every member | The “weak type” check only requires one shared property, so a typo can slip through if it happens to match another field’s name |
Common Misconceptions
Section titled “Common Misconceptions”- A union of object types with a shared boolean or string property automatically narrows on that property. It doesn’t unless that property is a literal discriminant unique to each member (the narrowing lesson’s
kind: "circle"pattern) — checking a sharedok: booleantruthy value narrows nothing. - Intersecting two incompatible object types is always an error. It’s not —
A & Bwith conflicting primitive property types just makes that property resolve tonever, and the type still exists (just practically unconstructable for that field). - A union rejects any object with an unrecognized property, the same as a single object type would. It only rejects a fresh literal with no properties in common with any member — an existing variable, or one that shares even one property, gets through untouched.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting a generated union or intersection, check for:
- An
if (obj.someSharedBooleanProperty)used to narrow a union of object types — if that property isn’t a per-member literal, this doesn’t narrow anything, and every subsequent property access is checked against the whole union. - A union parameter accepting an object that was built by spreading or merging from an untyped source — the “weak type” check only needs one matching property name to let the whole object through, typos included.
- A function typed as an intersection of call signatures (
FnA & FnB) whose actual implementation only really handles one of them — the type promises both are safe to call, but nothing checks the body actually branches correctly at runtime.
🐛 Spot the bug
interface SuccessResult { data?: unknown; ok?: boolean }interface ErrorResult { message?: string; ok?: boolean }
function handle(result: SuccessResult | ErrorResult) {console.log(result.ok);}
const response = { ok: true, dta: "typo -- meant 'data', but shares 'ok' with both members" };handle(response); // does the typo get caught?Show the answer
This compiles with zero errors. response is not a fresh literal argument (it’s a variable), so the ordinary excess-property check doesn’t apply, and the union’s “weak type” check only demands at least one shared property — ok satisfies that for both SuccessResult and ErrorResult, so the typo’d dta field rides along completely unnoticed. The type system’s promise here is much weaker than it looks: sharing one property name with a union member is enough to pass, regardless of what else is wrong with the object.