Lesson 12 · Module 1.7 — Core type vocabulary

Wider & narrower types — union, intersection, and variance

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.

Picture it: one fruit box (apples & oranges)

Keep a single picture in mind — apples and oranges:

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.

On primitives it behaves as you'd guess

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.

On objects it flips your intuition

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.

A type is a set of values, not a bag of properties

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 Bonly things that fit both A and B
so how many qualify?
(the “size of the set”)
more values — adding choices grows the set → widerfewer values — adding demands shrinks it → narrower
primitive examplestring | number = any string or numberstring & number = never (nothing is both)
object examplea value needs one shape's propertiesa 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.

The one trap

“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).

What is variance?

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?

1 · Take fruit OUT → narrower is fine (covariant)

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.

2 · Put fruit IN → you need wider (contravariant)

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.)

PECS, in one line

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.)

3 · Do BOTH → it must match exactly (invariant)

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
One honest catch

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.

The whole thing in one box

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.

Check yourself

Recall, don’t re-read. (Answers reveal on click.)

What does the primitive intersection string & number resolve to?
An object typed Circle & Rectangle must have…
A readonly Apple[] is assignable to a readonly Fruit[]. This is…
An InBox<Fruit> (put-in only) is assignable to an InBox<Apple> because put-in boxes are…

Primary source

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.

Ask your teacher

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.