open atlas
↑ Back to track
TypeScript type system, deep TS · 03 · 05

Variance intuition

Variance is the direction subtyping flows through a type constructor. Function parameters are contravariant, arrays are unsoundly covariant, and method parameters stay bivariant even under strictFunctionTypes — the gotcha that lets a too-loose callback slip through.

TS Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior

A registry takes (handler: (e: Event) => void). You pass (e: MouseEvent) => void because your handler only fires on clicks — and it type-checks. Then a non-mouse Event arrives, your handler reads e.button, and you get undefined at runtime with no compiler warning. The hole is variance, and the reason it slipped through is a TypeScript default most engineers never learn they opted into.

Three relationships, one question

Given Dog extends Animal (every Dog is an Animal), variance asks: for a type constructor F<_>, how does F<Dog> relate to F<Animal>?

  • CovariantF<Dog> is assignable to F<Animal> (subtyping flows the same direction). Read-only producers.
  • ContravariantF<Animal> is assignable to F<Dog> (subtyping flows the opposite way). Consumers of input.
  • Invariant — neither direction; only exact matches.
  • Bivariant — both directions accepted (unsound; TS does this deliberately in one place).

Together these four cases cover every position a type variable can occupy: whether a type constructor produces, consumes, or both determines which direction is safe. The next three sections show each one in real TypeScript, including the case where TS knowingly picks the unsound option.

Why function parameters are contravariant

A function that accepts an Animal can stand in wherever a function accepting a Dog is required — because anything that hands it a Dog is handing it a valid Animal. The reverse is unsafe:

type Animal = { name: string };
type Dog = { name: string; bark(): void };

const feedAnimal = (a: Animal) => a.name;
let feedDog: (d: Dog) => string;

feedDog = feedAnimal; // ok — a Dog IS an Animal, feedAnimal handles it
// the unsafe direction:
let feedAnimal2: (a: Animal) => string;
const handleDog = (d: Dog) => d.bark();
// feedAnimal2 = handleDog;
// Error (under strictFunctionTypes): handleDog needs bark(), but it may
//        be called with a plain Animal that has no bark.

Accepting a wider input is safe; accepting a narrower input than the slot promises is a hole — calling it with the wider value the slot allows would touch members that aren’t there. So standalone function parameters are contravariant.

Why arrays are (unsoundly) covariant

TypeScript treats Array<Dog> as assignable to Array<Animal> even though arrays are mutable — a deliberate, known unsoundness for ergonomics:

const dogs: Dog[] = [{ name: "Rex", bark: () => {} }];
const animals: Animal[] = dogs; // allowed — covariant arrays
animals.push({ name: "Whiskers" }); // pushes a non-Dog into a Dog[]!
dogs[1].bark();
// Runtime: bark is not a function — the type system did not stop us.

A sound system would make the mutable element position invariant. TS chooses covariance because read-heavy array usage dominates and full invariance would be painful. Knowing this hole exists is the senior takeaway.

The bivariant-methods gotcha

Now the hook’s bug. strictFunctionTypes (TS 2.6) makes standalone function-type parameters contravariant — but, by design, it does not apply to method parameters, which stay bivariant. Same-looking declarations behave differently:

// tsconfig: { "strictFunctionTypes": true }
type Handler = (e: Event) => void;

// METHOD syntax — parameter is BIVARIANT (the gotcha):
interface BusMethod {
  on(handler: (e: Event) => void): void;
}
// PROPERTY syntax — parameter is CONTRAVARIANT (checked):
interface BusProp {
  on: (handler: (e: Event) => void) => void;
}

declare const m: BusMethod;
declare const p: BusProp;

const narrow = (e: MouseEvent) => e.button;

m.on(narrow); // ✅ NO ERROR — method param is bivariant (unsound!)
p.on(narrow);
// ❌ Error: '(e: MouseEvent) => number' is not assignable to '(e: Event) => void'
//    Types of parameters 'e' are incompatible:
//    'Event' is missing the properties of 'MouseEvent' (button, ...).

The only difference is method shorthand (on(...)) versus a property holding a function (on: (...) => ...). The method form lets the too-narrow MouseEvent handler through, so when a plain Event is dispatched, e.button is undefined. This is why Array.prototype methods and many DOM/event APIs accept callbacks that are technically too narrow — the bivariance is baked into the method declaration form.

Variance annotations: in / out (TS 4.7)

TS 4.7 lets you state the intended variance of a type parameter with in (contravariant) and out (covariant). The checker then verifies your usage matches — and the annotation also documents intent and can speed up checking:

interface Producer<out T> {  // T only appears in output positions
  get(): T;
}
interface Consumer<in T> {   // T only appears in input positions
  set(value: T): void;
}
interface Box<in out T> {    // T is both read and written → invariant
  get(): T;
  set(value: T): void;
}

// If you annotate `out` but then write T in an input position, you get:
interface Bad<out T> {
  set(value: T): void;
// Error: Type 'T' is declared covariant ('out') but occurs in a
//        contravariant position.  (roughly)
}

Annotations don’t change assignability rules the checker already infers; they let you assert and enforce the variance you intend, catching the mistake of, say, adding a setter to a type you promised was a pure producer.

The numbers and versions behind the holes

Each of these behaviors is pinned to a specific release, and the dates matter when you read a tsconfig or chase a “why did this ever type-check” question. Bivariant method parameters and unsound array covariance predate strict mode entirely. strictFunctionTypes landed in TS 2.6 (Oct 2017) and tightened standalone function-type parameters to contravariant — but by deliberate design left method-shorthand and constructor parameters bivariant, which is the exact hole the hook exploits. Variance annotations in/out arrived in TS 4.7 (May 2022). So a handler bug that slips through a method slot is not a compiler defect to file — it is a nine-year-old, documented tradeoff, and "strictFunctionTypes": false (or the absence of strict: true) silently widens the hole to all function parameters.

The annotations also have a measured performance payoff, which is why large codebases adopt them beyond documentation value. Inferring variance for a deeply recursive generic type is work the checker repeats; stating out T / in T lets it skip that inference and use your declaration directly. The TS team reported variance-annotation and related caching changes cutting structural-comparison time on heavy generic types substantially — on the order of tens of percent of check time on the worst recursive types — which is the difference between a responsive editor and a laggy one on a large schema or a deeply generic ORM type. The annotation is thus three things at once: documentation, a correctness guard against a mis-declared producer/consumer, and a checker hint that can claw back check time.

Why this works

Why did TS choose unsound array covariance and bivariant methods at all? Both predate strictFunctionTypes. Method bivariance specifically keeps Array<T>, Promise<T>, and event-style APIs ergonomic: arr.forEach((x: Dog) => ...) on a wider array, or addEventListener("click", (e: MouseEvent) => ...), would otherwise error constantly. The cost is real holes. The senior move is to (1) prefer the property-function declaration form when you want the parameter actually checked, and (2) know that a callback accepted by a method may be narrower than the declared signature, so never read fields on the callback’s parameter that the declared (wider) type doesn’t guarantee.

The tradeoff TypeScript made — and the one you inherit — is soundness traded for ergonomics, and a senior accepts it consciously rather than fights it. Demanding full soundness here would mean invariant arrays (constant casting on read-heavy code) and contravariant method parameters (every addEventListener("click", (e: MouseEvent) => …) and arr.forEach((x: Dog) => …) erroring), which is why the language chose the holes. What you give up by accepting them is a compile-time guarantee at exactly two spots; what you buy is APIs that don’t fight you. The discipline is not to eliminate the unsoundness but to fence it: use the property-function form where a callback’s variance genuinely matters, never trust a callback parameter’s narrow type beyond what the declared type guarantees, and reach for in/out on your own generics so the variance you intend is enforced rather than left to inference.

Quiz

Given `Dog extends Animal`, which assignment is sound for standalone function types under strictFunctionTypes?

Quiz

Why does `m.on((e: MouseEvent) => ...)` type-check while `p.on((e: MouseEvent) => ...)` errors, when both `on` expect `(e: Event) => void`?

Order the steps

Order the reasoning that shows mutable-array covariance is unsound (given Dog extends Animal):

  1. 1 TS allows Dog[] to be assigned to a variable typed Animal[] (covariant arrays)
  2. 2 Through the Animal[] alias, push a plain Animal that is not a Dog
  3. 3 The underlying array — still a Dog[] elsewhere — now contains a non-Dog
  4. 4 Code reading it as Dog[] calls a Dog-only method like bark()
  5. 5 At runtime bark is not a function, with no compile error having fired
Complete the analogy

Fill in the blank: function parameters are _______ — a function accepting a wider input type may stand in for one accepting a narrower input, never the reverse.

Recall before you leave
  1. 01
    Define covariance, contravariance, and invariance using Dog extends Animal, and say which applies to function parameters, arrays, and a mutable box.
  2. 02
    Explain the strictFunctionTypes method-vs-property gotcha and the practical rule it implies.
Recap

Variance is the direction subtyping flows through a type constructor: covariant for producers, contravariant for the consumers that function parameters are, invariant for the mutable containers that need both. TypeScript knowingly trades soundness for ergonomics in two places — arrays are covariant despite being mutable, and method-shorthand parameters stay bivariant even under strictFunctionTypes, which is why a too-narrow callback slips into a method slot and blows up at runtime. The defences are the property-function declaration form for genuinely checked callbacks and the in/out annotations (TS 4.7) to assert intent. That closes the generics unit; the real-world unit revisits these exact holes as production pitfalls. Now when you see a callback registered through a method slot compile without error, ask yourself: is this parameter actually checked — or did it slip through on bivariance?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.