Phase 7 · Advanced and tooling · Lesson 7.1
AdvancedVariance
Covariance, contravariance, bivariance and invariance: why a Dog[] is an Animal[], why a handler of Dogs is not a handler of Animals, and where TypeScript deliberately lets unsound code through.
20 min
You have a function that accepts a list of animals, and you pass it a list of dogs. Fine. You have a callback slot that expects an animal handler, and you plug in a dog handler. Is that fine too? It looks symmetrical, but it is the opposite answer. Variance is the name for how "is a subtype of" travels through generic types, arrays and functions, and it is where most "why does this compile?" interview questions live.
Refresher: subtyping and assignability
TypeScript is structural: a type A is assignable to B when A has everything B requires. A Dog with an extra bark method is a subtype of Animal.
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
declare const dog: Dog;
const a: Animal = dog; // ok: Dog has everything Animal needs
declare const animal: Animal;
// @ts-expect-error -- Property 'bark' is missing in type 'Animal' but required in type 'Dog'.
const d: Dog = animal;Write this as Dog <: Animal ("Dog is a subtype of Animal"). Variance asks one question about a type constructor F<T>: given Dog <: Animal, what can we say about F<Dog> and F<Animal>?
| Variance | Rule | Typical position of T |
|---|---|---|
| Covariant | F<Dog> <: F<Animal> (same direction) | output: return types, readonly properties |
| Contravariant | F<Animal> <: F<Dog> (flipped) | input: function parameters |
| Invariant | neither direction | both input and output |
| Bivariant | both directions | TypeScript's legacy rule for methods |
Covariance: outputs keep the direction
Anything you only read from is covariant. If a function returns a Dog, a caller expecting an Animal is happy.
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
type Producer<T> = () => T;
declare const makeDog: Producer<Dog>;
const makeAnimal: Producer<Animal> = makeDog; // ok: covariant
declare const dogs: readonly Dog[];
const animals: readonly Animal[] = dogs; // ok: readonly arrays are covariantA readonly Dog[] can only hand out dogs, and every dog is an animal, so treating it as a readonly Animal[] is sound: nothing can go wrong.
Contravariance: inputs flip the direction
A function parameter is an input. If a slot expects "something that can handle any Animal", a function that only handles Dogs is not good enough: someone will pass it a Cat.
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
type Handler<T> = (value: T) => void;
const handleAnimal: Handler<Animal> = (a) => console.log(a.name);
const handleDog: Handler<Dog> = (d) => d.bark();
const h1: Handler<Dog> = handleAnimal; // ok: an Animal handler handles dogs too
// @ts-expect-error -- Type 'Handler<Dog>' is not assignable to type 'Handler<Animal>'.
const h2: Handler<Animal> = handleDog; // would call bark() on a Cat▶ Try it in the TypeScript Playground
The arrow flipped: Dog <: Animal, but Handler<Animal> <: Handler<Dog>. That is contravariance.
strictFunctionTypes
Before TypeScript 2.6, function parameters were checked bivariantly: h2 above was allowed. The strictFunctionTypes flag (part of strict) switched function-type parameters to contravariant checking. It is on in every strict project, so the error above is what you'll see.
Quick check
With strict on, which assignment is an error?
declare const toStr: () => string;
declare const takesStr: (x: string) => void;
const a: () => string | number = toStr; // Line A
const b: (x: string | number) => void = takesStr; // Line BThe loophole: methods are bivariant
strictFunctionTypes only applies to function type syntax. Parameters of methods (declared with method shorthand) are still checked bivariantly, on purpose.
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
interface MethodStyle { handle(a: Animal): void } // method shorthand
interface PropertyStyle { handle: (a: Animal) => void } // function property
declare const dogMethod: { handle(d: Dog): void };
declare const dogProp: { handle: (d: Dog) => void };
const m: MethodStyle = dogMethod; // ok: method params are bivariant
// @ts-expect-error -- Types of property 'handle' are incompatible.
const p: PropertyStyle = dogProp;Why keep the hole open? Because of built-in types like Array<T>. Array has methods such as push(...items: T[]) and indexOf(item: T) that take T as input, which would make Array<T> invariant under strict checking. Then Dog[] would not be assignable to Animal[], and a huge amount of real code (including the DOM types, where event handlers are methods) would break. Method bivariance is the pragmatic compromise.
The mutable-array unsoundness
Method bivariance has a price. Mutable arrays are treated as covariant, which is unsound: this compiles cleanly and crashes at runtime.
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
interface Cat extends Animal { meow(): void }
const dogs: Dog[] = [];
const animals: Animal[] = dogs; // allowed: arrays are treated as covariant
const cat: Cat = { name: "Tom", meow() {} };
animals.push(cat); // allowed: push takes an Animal
dogs[0].bark(); // type-checks, throws at runtime: bark is not a functiondogs and animals are the same array, so a cat got into the dog list. Truly sound typing would make mutable arrays invariant; TypeScript chose usability. The fix in your own code is simple: accept readonly T[] when a function only reads.
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
function names(list: readonly Animal[]): string[] {
return list.map((a) => a.name);
}
declare const dogs: Dog[];
names(dogs); // ok, and names() cannot push anything into dogs
function addStray(list: readonly Animal[]) {
// @ts-expect-error -- Property 'push' does not exist on type 'readonly Animal[]'.
list.push({ name: "stray" });
}Quick check
Which line is a compile error?
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
interface Cat extends Animal { meow(): void }
declare const cat: Cat;
const dogs: Dog[] = [];
const animals: Animal[] = dogs; // Line 1
animals.push(cat); // Line 2
dogs[0].bark(); // Line 3Invariance: in and out at once
When T is both read and written, only an exact match is safe. A mutable box of Dog must not become a box of Animal (you could store a cat), and a box of Animal must not become a box of Dog (you could read a cat).
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
interface Box<T> {
get: () => T; // T in output position
set: (value: T) => void; // T in input position (function property: strict)
}
declare const dogBox: Box<Dog>;
declare const animalBox: Box<Animal>;
// @ts-expect-error -- Type 'Box<Dog>' is not assignable to type 'Box<Animal>'.
const a: Box<Animal> = dogBox;
// @ts-expect-error -- Type 'Box<Animal>' is not assignable to type 'Box<Dog>'.
const b: Box<Dog> = animalBox;Note that the set member uses function-property syntax. Written as a method, set(value: T): void, the parameter would be bivariant and Box would become covariant again.
Explicit variance annotations: in and out
TypeScript normally measures variance: it compares Box<sub> with Box<super> structurally, then caches the result. Since TypeScript 4.7 you can declare it on a type parameter:
out T: covariant (T is only produced)in T: contravariant (T is only consumed)in out T: invariant
interface Producer<out T> { make: () => T }
interface Consumer<in T> { take: (value: T) => void }
interface Cell<in out T> { get: () => T; set: (value: T) => void }
// A wrong annotation is an error at the declaration:
// @ts-expect-error -- Type 'Getter<super-T>' is not assignable to type 'Getter<sub-T>' as implied by variance annotation.
interface Getter<in T> { get: () => T }The compiler checks your annotation against the structure. So what are they for?
- Performance. For big, deeply recursive generic types, measuring variance can be expensive. An annotation lets the checker skip the measurement.
- Circular types. When variance measurement hits a type that references itself, TypeScript can fall back to a less precise answer. An annotation removes the guesswork.
- Documentation.
out Tstates intent to readers.
What they are not for: making a type stricter or looser than its structure. You cannot use out to make a type that actually consumes T covariant.
Spot the error
A teammate writes an event system. It compiles, but a bug slipped through at a call site. Why didn't TypeScript catch it?
interface UIEvent { type: string }
interface ClickEvent extends UIEvent { x: number; y: number }
interface Listener {
handle(event: UIEvent): void;
}
const clickListener: Listener = {
handle(event: ClickEvent) {
console.log(event.x.toFixed(0)); // crashes if event is not a click
},
};
clickListener.handle({ type: "keydown" });Show the answer
handle is declared with method shorthand, so its parameter is checked bivariantly: a handler of ClickEvent is accepted where a handler of any UIEvent is required. At runtime event.x is undefined and .toFixed throws.
Declare it as a function property so strictFunctionTypes applies, and the bug becomes a compile error:
interface UIEvent { type: string }
interface ClickEvent extends UIEvent { x: number; y: number }
interface Listener {
handle: (event: UIEvent) => void;
}
const clickListener: Listener = {
// @ts-expect-error -- Type '(event: ClickEvent) => void' is not assignable to type '(event: UIEvent) => void'.
handle: (event: ClickEvent) => {
console.log(event.x.toFixed(0));
},
};The real fix is then to accept UIEvent and narrow on event.type.
Where variance shows up in practice
- Callbacks.
arr.forEach((x: SubType) => ...)on an array of a supertype is rejected: the callback parameter is contravariant. - Returning a narrower type from an overridden method is fine (covariant return); accepting a narrower parameter in an override is allowed only because methods are bivariant.
- Generic class fields. A class with a public mutable
value: Tfield is effectively covariant in TypeScript (property writes are not checked contravariantly), another deliberate soundness trade-off.
Recap
- Variance describes how
Dog <: Animalcarries over toF<Dog>andF<Animal>. - Covariant: outputs (return types,
readonlyarrays). Contravariant: function parameters. Invariant: both. Bivariant: both directions allowed. strictFunctionTypes(instrict) makes function-type parameters contravariant, but not method-shorthand parameters.- Mutable arrays are treated as covariant, which is unsound; accept
readonly T[]when you only read. - Use function-property syntax (
cb: (x: T) => void) for strict callback checking. in/out/in outannotations (TS 4.7) declare variance; the compiler verifies them. They help performance and circular types, not semantics.
Interview cards
1 / 7