Topic 7.7
Variance
In one line
Variance answers one question: if a Dog can be used where an Animal is expected, can a Box<Dog> be used where a Box<Animal> is expected? The answer depends on whether the type produces values, consumes them, or both.
Think of it like this
Two services. A shop that sells dogs can stand in where "a shop that sells animals" is wanted, because everything it hands you is an animal. Now think of groomers. A groomer who can groom any animal can stand in where "a dog groomer" is wanted, but a groomer who only knows dogs can't stand in where "an animal groomer" is wanted, because a cat might arrive. What is allowed depends on the direction the animal travels: out of the shop (you receive it) or into the groomer (you hand it over).
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Subtype
- A type that has everything another type has, and possibly more.
Dogis a subtype ofAnimal. - Variance
- How the assignability of a generic type follows the assignability of its type argument: same way, opposite way, both or neither.
- Covariant
- Same direction.
Producer<Dog>can be used asProducer<Animal>. Safe whenTonly comes out. - Contravariant
- Opposite direction.
Consumer<Animal>can be used asConsumer<Dog>. Safe whenTonly goes in. - Invariant
- Neither direction. Only the exact same type argument fits. What you get when
Tgoes both in and out. - Bivariant
- Both directions allowed. Convenient and unsound; TypeScript keeps it for method parameters.
- Unsound
- Allowed by the checker even though it can crash at runtime. TypeScript has a few deliberate unsound spots.
- strictFunctionTypes
- A compiler option, part of
strict, that checks function parameters contravariantly.
Step by step
01Subtype first: Dog is an Animal
Dog extends Animal and adds bark. Every dog can be used where an animal is wanted. The reverse is not true. Everything in this topic builds on that one-way arrow.
02Outputs are covariant
A Producer<T> only hands out T. A producer of dogs is a producer of animals: whatever it makes is an animal. So the arrow keeps its direction: Producer<Dog> goes to Producer<Animal>.
interface Producer<out T> { make: () => T }
declare const dogMaker: Producer<Dog>;
const animalMaker: Producer<Animal> = dogMaker; // fine03Inputs are contravariant
A Consumer<T> only receives T. A consumer of any animal can stand in for a consumer of dogs, so the arrow flips: Consumer<Animal> goes to Consumer<Dog>.
The opposite is the error: a function that needs a Dog (it calls bark) can't be given an Animal.
type Feeder<T> = (animal: T) => void;
const feedAnimal: Feeder<Animal> = (a) => console.log(a.name);
const feedDog: Feeder<Dog> = (d) => console.log(d.bark());
const f1: Feeder<Dog> = feedAnimal; // fine
const f2: Feeder<Animal> = feedDog; // error: needs bark04In and out together: invariant
A type that both produces and consumes T (a get and a set) can't move in either direction. Declare Both<in out T> and the compiler rejects Both<Dog> as a Both<Animal>. Then it also rejects the reverse.
05The deliberate exceptions
Arrays are covariant even though they are mutable: Dog[] goes to Animal[]. So push can break the promise at runtime. This is a known hole.
Method-syntax parameters are bivariant: handle(x: Dog) is accepted where handle(x: Animal) is wanted. Write handle: (x: T) => void when you want strict checking.
Try it yourself
- 1
Make the push impossible
In the first example change
const animals: Animal[] = dogs;toconst animals: readonly Animal[] = dogs;. Read the error onanimals.push:Property 'push' does not exist on type 'readonly Animal[]'. The hole is closed. (The lineconst backToDogs: Dog[] = animals;now reportsTS4104, because a readonly array can't be assigned to a mutable one.) - 2
Flip the feeder
In the second example change
const feedDog: Feeder<Dog>toFeeder<Animal>. Predict what happens to the error onf2. It disappears, becausefeedDognow accepts any animal. But a new error appears insidefeedDog:d.bark()reportsProperty 'bark' does not exist on type 'Animal'. The type you promise has to match what the body needs. - 3
Use method syntax
In the second example change
type Feeder<T> = (animal: T) => void;tointerface Feeder<T> { (animal: T): void }. The error onf2stays: call signatures, like function types, are checked contravariantly. Only members declared with method syntax (handle(x: T): void) get the bivariant exception.
Code & diagrams
Dog[] to Animal[] is allowed (line 10) and Animal[] to Dog[] is not (line 11). But the first is unsound: push adds a plain Animal to the array that dogs still points to, and dogs[1].bark() fails at runtime. The readonly view can't push, so it is safe.
Console
(nothing was printed)Expected console output
2 2
Uncaught TypeError: dogs[1]?.bark is not a functionA feeder of any animal can serve as a dog feeder (line 14, fine). A dog feeder can't serve as an animal feeder (line 15): it would call bark on something that might not have it.
Console
(nothing was printed)Expected console output
feeding RexLine 19 (method syntax) is accepted although it is unsound: the last line calls a dog-only handler with a plain Animal and crashes. Line 20 (a function-typed property) is rejected under strictFunctionTypes. Line 21 is the safe direction.
Console
(nothing was printed)Expected console output
Rex
Uncaught TypeError: d.bark is not a functionout lets Producer go from Dog to Animal (line 23), in lets Consumer go from Animal to Dog (line 24). The reverse of Producer (line 25) is refused. Both is invariant: Both<Dog> does not fit Both<Animal> (line 26), and neither does the reverse. Only the exact same type would fit.
Console
(nothing was printed)Expected console output
ate with RexWrong claims T only comes out, but take consumes it. LooseMethod makes the same false claim and is accepted, because method parameters are bivariant: another reason to prefer property syntax when you care about variance.
Console
(nothing was printed)Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Claim the wrong variance
In the last example, Wrong<out T> says T only comes out, but has a take: (x: T) => void.
Break #2
Pass a dog-only function where an animal function is needed
In the second example line 15 is const f2: Feeder<Animal> = feedDog;.
Myth vs fact
Myth
If Dog is assignable to Animal, then Box<Dog> is assignable to Box<Animal>.
Fact
Only if Box is covariant in T, meaning T only comes out. If Box can also be written to (or T is a function parameter), the direction is reversed or blocked.
Myth
TypeScript is sound, so Dog[] to Animal[] must be safe.
Fact
TypeScript is intentionally not fully sound. Array covariance, method bivariance, type assertions and any are all deliberate holes. Knowing them is how you avoid the runtime bugs the checker can't see.
Myth
You need to write in and out annotations all the time.
Fact
The compiler works variance out by itself in almost every case. Annotations are for library authors who want to document the contract, and sometimes to help the compiler with very deep types.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The checker works variance out by probing: it compares
Foo<MarkerSub>withFoo<MarkerSuper>once per type parameter, in both directions, and caches the result as a variance flag set. That is why error messages showsub-Tandsuper-T. Explicitin/outgive the answer directly (the compiler still verifies it,TS2636), which can help for large recursive generics. - ▸
A property typed
readonly value: Tis covariant, but a plainvalue: Tis checked as covariant too (TypeScript does not treat writable properties as invariant). That is another deliberate unsoundness, like arrays, chosen to keep object-shaped code flexible. Only functions are made strict. - ▸
strictFunctionTypesdoes not affect methods and constructors (members written in method syntax), including the ones inlib.d.ts. If a callback type must be strict, spell it as a function-typed property:onChange: (value: T) => void. This is the rule behind many event-handler type surprises in React and DOM code.
Remember this
- 1
The problem this solves: It is not obvious whether a
Box<Dog>may be used where aBox<Animal>is expected. Getting it wrong lets a wrong value in, or blocks code that is safe. Some words first.Dogis a subtype ofAnimal(aDoghas everything anAnimalhas, andbark). Anywhere anAnimalis wanted, aDogcan be used. This is assignability. Variance asks what happens one level up, to a generic type built from them: isBox<Dog>assignable toBox<Animal>, or the other way round, or neither? - 2
Covariant means the generic type goes the same way as its argument:
Dogis assignable toAnimal, soProducer<Dog>is assignable toProducer<Animal>. This is right whenTonly comes out of the type: amake(): Tmethod or areadonlyfield. Everything you receive from a dog-maker is an animal. Areadonly Animal[]can hold aDog[]for this reason. - 3
Contravariant means it goes the opposite way:
Consumer<Animal>is assignable toConsumer<Dog>. This is right whenTonly goes in: a function parameter. A function that can feed any animal can certainly feed a dog. But a function that only handles dogs can't be used where one that feeds any animal is needed. ADogneedsbark; anAnimalargument doesn't have it. The error you meet isType 'Feeder<Dog>' is not assignable to type 'Feeder<Animal>'. - 4
Invariant means neither direction is safe:
Tgoes both in and out (for example aget: () => Tfunction plus aset: (x: T) => voidfunction).Box<Dog>isn't anAnimalbox (someone could put a cat in it), andBox<Animal>isn't aDogbox (you might read a cat out). Only the exact sameTfits. Bivariant means both directions are allowed. It is not safe, and it exists only for compatibility. - 5
TypeScript deliberately lets arrays be covariant:
const animals: Animal[] = dogscompiles even though arrays are read and written. This is a known unsoundness:animals.push(new Animal("x"))puts a plain animal into the array thatdogsstill points to, anddogs[1]?.bark()crashes at runtime (try it in the first example). The language trades a little safety for the convenience of usingDog[]whereAnimal[]is expected, which is extremely common in real code.readonly Animal[]is the safe version, because it can't be written to. - 6
**
strictFunctionTypes** (on instrict) makes function parameters contravariant, which is correct. There is one exception, and it is deliberate: parameters of methods written in method syntax (handle(x: T): void) are still compared bivariantly, while parameters of function-typed properties (handle: (x: T) => void) are checked contravariantly. That's whyWithMethod<Dog>is accepted whereWithMethod<Animal>is wanted (unsound), butWithProperty<Dog>is rejected. The exception exists becauseArray<T>,Set<T>and many DOM types declare methods, and making them invariant would break most existing code. - 7
Since TypeScript 4.7 you can state the variance yourself:
interface Producer<out T>,interface Consumer<in T>,interface Both<in out T>. The compiler checks that your annotation matches howTis used and reportsTS2636if it doesn't. The annotations make intent visible and, for some deep generic types, can help the compiler too. Beginners almost never need them; library authors do.
Explain it without notes
Explain covariance, contravariance and invariance with an example.
Why are TypeScript's arrays covariant, and what goes wrong?
What does strictFunctionTypes change, and what does it not change?
What are in and out annotations for?
Practice
Write interface ReadonlyBox<out T> { get: () => T } and show ReadonlyBox<Dog> assignable to ReadonlyBox<Animal>. Then add a set and see the annotation fail.
Given class Animal and class Dog extends Animal, decide which of these compile: const a: Array<Animal> = new Array<Dog>(), const b: Set<Dog> = new Set<Animal>(), const c: Promise<Animal> = Promise.resolve(new Dog("d")).
A function onAnimal(handler: (a: Animal) => void) is called with (d: Dog) => .... Does it compile? Rewrite the parameter type of onAnimal so it would compile with a method-syntax interface and explain why that is unsafe.
Trade-offs
- ↔
Strict variance catches real bugs but rejects some code that works in practice (a read-only use of a mutable type). The escape hatches (readonly views, method syntax, assertions) each trade a little safety for convenience. Pick them deliberately and keep them small.
- ↔
Explicit
in/outannotations document the contract (and can sometimes help the compiler), but they are a promise you must keep: a later edit that adds a write method turns a harmless change into an error. For application code, leave variance to inference.
Done when you can
I can say what covariant, contravariant, invariant and bivariant mean, with
DogandAnimal.I can say which direction applies when
Tcomes out and when it goes in.I can show the array covariance hole and the safe
readonlyalternative.I can explain
strictFunctionTypesand the method-syntax exception.I can use
inandoutand readTS2636.