Lesson 12 · Module 1.7 — Core type vocabulary
One thread ties this whole lesson together: a type is a set of values, and some
sets are wider, some narrower. | and & are how you build
wider and narrower types; variance is the rule for when a narrower type may stand in for a wider
one once it's wrapped inside a function. Same idea, start to finish.
| is “or” (wider), & is “and” (narrower)A union A | B is a value that is either an A or a
B. An intersection A & B is a value that is both an
A and a B at once. The symbols are the logical ones on purpose: | = or,
& = and.
Keep a single picture in mind — apples and oranges:
Apple | Orange is a box with one fruit — apple or orange,
and you're not told which. Both have vitamin C, so you can use that with
no check. But only an orange gives juice, so before juicing you must first
check it's an orange (it might be an apple). That check is narrowing.& stacks traits that can hold together. A fruit
that is HasVitaminC & HasVitaminA must have both vitamins — one fruit easily does — so
both are guaranteed and you use either with no check. (But Apple & Orange?
No fruit is both an apple and an orange, so that one is never — just like
string & number.)One line: | = “could be either — check before the unique bits,” & =
“every trait guaranteed — use them all freely.”
// union — vitamin C is shared, but juice is orange-only
function serve(fruit: Apple | Orange) {
fruit.vitaminC; // ✅ both fruits have it — no check
if (fruit.kind === "orange") fruit.juice(); // ✅ orange-only — must narrow first
}
// intersection — both traits hold at once, so both are guaranteed
function pick(fruit: HasVitaminC & HasVitaminA) {
fruit.vitaminC; fruit.vitaminA; // ✅ both always present — no check
}
The union is wider (more fruits qualify, but you know less about any one); the intersection is narrower (fewer qualify — only a fruit with all the traits — but you know more about it). That width is the formal idea just below.
type U = string | number; // either a string or a number
type I = string & number; // never — no value is both a string AND a number
string & number is never (the empty type from
Lesson 1.6): there is no single value that is simultaneously a string and a
number, so the “and” leaves you with nothing.
Here is where readers trip. With object types, the intersection & ends up
demanding more properties, not fewer:
type Circle = { color: string; radius: number };
type Rectangle = { color: string; width: number; height: number };
// Circle | Rectangle = be a COMPLETE Circle, OR a COMPLETE Rectangle
const a: Circle | Rectangle = { color: 'red', radius: 15 }; // ✅ a full Circle
const b: Circle | Rectangle = { color: 'red', width: 30, height: 20 }; // ✅ a full Rectangle
const c: Circle | Rectangle = { color: 'red' }; // ❌ color alone is neither shape
// Circle & Rectangle = be a Circle AND a Rectangle at the same time
const d: Circle & Rectangle = { color: 'red', radius: 15, width: 30, height: 20 }; // ✅ needs ALL four
So “match one of the shapes” does not mean “just give the shared
color.” It means be a whole Circle (color + radius)
or a whole Rectangle (color + width +
height). Plain { color } (the c above) is neither, so it's rejected.
And Circle & Rectangle says “be both at once,” so the value must satisfy
every requirement of both shapes — all four properties. That's why “and”
seems to add properties: to clear two bars instead of one, you need everything both bars ask for.
The usual mistake is to read a type as its list of properties. It isn't — a type is the set of values that belong to it (think “which values am I allowed to be?”). Read each column down and ask two things: which values get in, and how many?
A | B — union (“or”) | A & B — intersection (“and”) | |
|---|---|---|
| which values are allowed? | anything that fits A, or anything that fits B | only things that fit both A and B |
| so how many qualify? (the “size of the set”) | more values — adding choices grows the set → wider | fewer values — adding demands shrinks it → narrower |
| primitive example | string | number = any string or number | string & number = never (nothing is both) |
| object example | a value needs one shape's properties | a value needs every property of both shapes |
So “size of the set” just means how many different values are allowed to have that type. A union lets in more (wider); an intersection lets in fewer (narrower). The twist: on objects, the smaller set (intersection) demands more properties — because the more you require of a value, the fewer values can satisfy it.
“Intersection” sounds like “the bits in common,” so people expect Circle & Rectangle to
keep only color. Wrong — it's the intersection of the value sets, not the property
sets. Fewer values, more properties.
Quick real-world reflexes: reach for & to bolt fields onto a shape
(type Res = Data & ErrorHandling), and for | to say “one of these”
— the basis of a discriminated union (Lesson 3.6).
One fact to lean on: an Apple is a Fruit, so an apple goes wherever a fruit is
wanted. Variance asks the same thing about a box of them: is a box of
apples usable as a box of fruit? The twist — it depends entirely on what the box lets
you do: only take fruit out, only put fruit in, or both.
Three outcomes, and those three are all the scary words mean:
This is exactly what decides whether an assignment compiles — it's behind every “X is not assignable to
Y” error on arrays and callbacks. Our cast: Apple and Orange are both
Fruit, but only Apple has .crunch:
class Fruit { color!: string; }
class Apple extends Fruit { crunch!: () => void; } // a narrower Fruit (adds .crunch)
class Orange extends Fruit { peel!: () => void; } // another Fruit — note: no .crunch
Which outcome you get is one question — Java folks call it PECS: at the box, do you take fruit out, or put fruit in?
If you only ever read the box — take fruit out — a box of apples is a perfectly good box of fruit:
everything you pull out is an apple, and an apple is a fruit. A readonly array is exactly that box:
const apples: readonly Apple[] = [new Apple()];
const fruit: readonly Fruit[] = apples; // ✅ read-only — every item you take out is a Fruit
The narrower box (apples) stood in for the wider one (fruit) — the “is-a” survived. That's
covariant. Java calls a take-from box a producer and writes it
? extends T — the “Producer Extends” in PECS.
Now the opposite box: one you can only put fruit into, never take any out. TypeScript ships
readonly T[] for take-out-only, but has no built-in put-in-only box — so here's a tiny one whose
only job is to swallow a fruit:
interface InBox<T> { put: (item: T) => void; } // you can only PUT items in
const fruitBox: InBox<Fruit> = { put: f => console.log(f.color) };
const appleBox: InBox<Apple> = fruitBox; // ✅ a "takes ANY fruit" box works as an apple-box
appleBox.put(new Apple()); // ✅ appleBox's type promises Apple — this is one
appleBox.put(new Orange()); // ❌ same type — Orange isn't an Apple (the box could cope, but callers are judged by the type)
It flipped: the wider box (takes any fruit) stood in where the narrower one
(apples) was asked — the reverse of section 1. That's contravariant. Why wider? An
apples-only box would choke the moment someone put an orange in; coping with more is safe,
coping with less is not. Java calls a put-into box a consumer and writes it
? super T — the “Consumer Super”. (That put is just a function, so a
box's put-in slot is a parameter — which is why parameters are the contravariant spot.)
Producer (you take fruit out) → Extends → covariant, may be
narrower.
Consumer (you put fruit in) → Super → contravariant,
must be wider. That's the whole topic. (TypeScript has no ? extends / ? super
syntax — it applies the rule for you, from whether the spot takes fruit out or puts it in.)
A normal (mutable) array lets you do both — take fruit out and push fruit in. When both
happen, no swap is safe either way, so the boxes must match exactly. That's invariant. But
TypeScript bends this rule for convenience — it lets you treat a mutable apple-box as a fruit-box — which opens a
hole:
const box: Apple[] = [new Apple()];
const asFruit: Fruit[] = box; // ⚠️ TS allows it (mutable arrays are covariant)…
asFruit.push(new Orange()); // 💥 …now an Orange sits in what `box` thinks is all Apples
So a read-and-write box of apples is not safe as a box of fruit. The fix is to make it read-only — a pure take-from box — which is sound again:
const safe: readonly Fruit[] = box; // ✅ read-only = take out only → sound
We wrote put: as an arrow property on purpose. The put-in (contravariant) rule is only
enforced under strictFunctionTypes, and even then not for
method-shorthand parameters — a put(item: T): void method would be checked loosely
(“bivariant”), letting the wrong-direction swap slip through. When a bad callback gets past the compiler,
it's almost always that, not a hole in the rule.
Picture a fruit box with a label:
Label “Fruit or narrower” (? extends Fruit) → safe to READ. You don't know
exactly what's inside — could be all apples, could be all bananas. But every item is at least a Fruit,
so pulling one out and using any Fruit trait is always safe. Putting one in is banned: if it's
secretly an all-banana box, your apple doesn't belong, and the compiler can't prove it does. A take-out-only box
→ covariant → a producer (Producer Extends) — that's section 1's
readonly Apple[].
Label “Fruit or wider” (? super Fruit) → safe to WRITE. This box holds Fruit,
or something more general (maybe really a box of “any food”). So dropping in any fruit — apple, orange —
is always safe, because a fruit belongs in anything that accepts fruit-or-wider. Pulling one out is
useless: all you're promised back is a plain object; you've forgotten it was a fruit. A put-in-only box
→ contravariant → a consumer (Consumer Super) — that's section 2's InBox.
One line: read from the narrow box, write to the wide box. Get → Extends, Put → Super. That's PECS — and that's variance.
Recall, don’t re-read. (Answers reveal on click.)
string & number resolve to?Circle & Rectangle must have…readonly Apple[] is assignable to a readonly Fruit[]. This is…InBox<Fruit> (put-in only) is assignable to an InBox<Apple> because put-in boxes are…For union & intersection, the handbook pages are
Everyday Types — Unions
and Object Types — Intersection.
For variance, the authoritative TS source is the
2.6 release notes — --strictFunctionTypes
(underlying CS: Covariance and contravariance).
Best way to feel it: paste these snippets into the
TS Playground and try the unsafe swaps.
Stuck on a |/&, or a callback/array that won't assign? Paste it and ask — name
the wider/narrower set, then for variance ask “take out or put in?”: out → covariant, in → contravariant.