Skip to content

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 union

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

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
Union widens, intersection narrows

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).
  • string & number is never — no value is both, so the intersection is the empty set.
  • A union with any collapses to any; a union with never drops the never (T | never is just T, because adding the empty set changes nothing).
  • Intersecting object types with a conflicting primitive property can produce never for 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 unconstructable

That 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 TS2345
interface 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 first
paint(opts); // ❌ still rejected -- 'opts' shares no property with Circle or Square

This “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:

// @run
type 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 signatures
console.log(fn("hi")); // ✅ matches StrFn
console.log(fn(5)); // ✅ matches NumFn
// HI
// 10

A 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 TS2554
type 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 satisfied

This 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 signatures
console.log(fn("hi")); // matches StrFn
console.log(fn(5)); // matches NumFn
Open in TS Playground

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.

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"]
Union and intersection move the set boundary in opposite directions

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.

ApproachBenefitCost
Union (A | B)Models “one of several shapes,” matches real-world variance in dataOnly common members are accessible until you narrow
Intersection (A & B)Composes small, reusable object pieces without inheritanceFor conflicting primitive properties, the intersection collapses to never
Intersection of function typesBuilds one overloaded value from two signaturesThe runtime implementation must genuinely handle every signature — the type only promises it, it doesn’t provide it
Union of all-optional interfacesConvenient when every field really is optional in every memberThe “weak type” check only requires one shared property, so a typo can slip through if it happens to match another field’s name
  • 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 shared ok: boolean truthy value narrows nothing.
  • Intersecting two incompatible object types is always an error. It’s not — A & B with conflicting primitive property types just makes that property resolve to never, 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.

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.

While holding a value of type string | number, which members can you access without narrowing?
What does an intersection of two object types A & B require?
Which is wider (allows more values): a union or an intersection?
You want "a value that is one of several object shapes." Which should you usually reach for?
Does fn(5) compile?
type StrFn = (x: string) => string;
type NumFn = (x: number) => number;
type Overloaded = StrFn & NumFn;

declare const fn: Overloaded;
fn(5);
Does the typo'd dta property get caught?
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" };
handle(response);