Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate ~28 min· topic 7 of 7

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. Dog is a subtype of Animal.
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 as Producer<Animal>. Safe when T only comes out.
Contravariant
Opposite direction. Consumer<Animal> can be used as Consumer<Dog>. Safe when T only goes in.
Invariant
Neither direction. Only the exact same type argument fits. What you get when T goes 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.

Subtype first: Dog is an Animaldiagram
Rendering diagram…

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

covariant.tswhole filets
interface Producer<out T> { make: () => T }
declare const dogMaker: Producer<Dog>;
const animalMaker: Producer<Animal> = dogMaker; // fine

03Inputs 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.

contravariant.tswhole filets
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 bark
Inputs are contravariantdiagram
Rendering diagram…

04In 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. 1

    Make the push impossible

    In the first example change const animals: Animal[] = dogs; to const animals: readonly Animal[] = dogs;. Read the error on animals.push: Property 'push' does not exist on type 'readonly Animal[]'. The hole is closed. (The line const backToDogs: Dog[] = animals; now reports TS4104, because a readonly array can't be assigned to a mutable one.)

  2. 2

    Flip the feeder

    In the second example change const feedDog: Feeder<Dog> to Feeder<Animal>. Predict what happens to the error on f2. It disappears, because feedDog now accepts any animal. But a new error appears inside feedDog: d.bark() reports Property 'bark' does not exist on type 'Animal'. The type you promise has to match what the body needs.

  3. 3

    Use method syntax

    In the second example change type Feeder<T> = (animal: T) => void; to interface Feeder<T> { (animal: T): void }. The error on f2 stays: call signatures, like function types, are checked contravariantly. Only members declared with method syntax (handle(x: T): void) get the bivariant exception.

Code & diagrams

Arrays are covariant, and that has a price

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 function
Function parameters are contravariant

A 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 Rex
Method syntax is bivariant, property syntax is strict

Line 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 function
Saying the variance out loud: out, in and in out TypeScript 4.7+

out 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 Rex
A wrong annotation is an error TypeScript 4.7+

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

terminal
$ Types panel
── what you'll see ──
index.ts(1,17): error TS2636: Type 'Wrong<sub-T>' is not assignable to type 'Wrong<super-T>' as implied by variance annotation.

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

terminal
$ Types panel
── what you'll see ──
index.ts(15,7): error TS2322: Type 'Feeder<Dog>' is not assignable to type 'Feeder<Animal>'.
Property 'bark' is missing in type 'Animal' but required in type 'Dog'.

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> with Foo<MarkerSuper> once per type parameter, in both directions, and caches the result as a variance flag set. That is why error messages show sub-T and super-T. Explicit in/out give the answer directly (the compiler still verifies it, TS2636), which can help for large recursive generics.

  • ▸

    A property typed readonly value: T is covariant, but a plain value: T is 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.

  • ▸

    strictFunctionTypes does not affect methods and constructors (members written in method syntax), including the ones in lib.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. 1

    The problem this solves: It is not obvious whether a Box<Dog> may be used where a Box<Animal> is expected. Getting it wrong lets a wrong value in, or blocks code that is safe. Some words first. Dog is a subtype of Animal (a Dog has everything an Animal has, and bark). Anywhere an Animal is wanted, a Dog can be used. This is assignability. Variance asks what happens one level up, to a generic type built from them: is Box<Dog> assignable to Box<Animal>, or the other way round, or neither?

  2. 2

    Covariant means the generic type goes the same way as its argument: Dog is assignable to Animal, so Producer<Dog> is assignable to Producer<Animal>. This is right when T only comes out of the type: a make(): T method or a readonly field. Everything you receive from a dog-maker is an animal. A readonly Animal[] can hold a Dog[] for this reason.

  3. 3

    Contravariant means it goes the opposite way: Consumer<Animal> is assignable to Consumer<Dog>. This is right when T only 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. A Dog needs bark; an Animal argument doesn't have it. The error you meet is Type 'Feeder<Dog>' is not assignable to type 'Feeder<Animal>'.

  4. 4

    Invariant means neither direction is safe: T goes both in and out (for example a get: () => T function plus a set: (x: T) => void function). Box<Dog> isn't an Animal box (someone could put a cat in it), and Box<Animal> isn't a Dog box (you might read a cat out). Only the exact same T fits. Bivariant means both directions are allowed. It is not safe, and it exists only for compatibility.

  5. 5

    TypeScript deliberately lets arrays be covariant: const animals: Animal[] = dogs compiles even though arrays are read and written. This is a known unsoundness: animals.push(new Animal("x")) puts a plain animal into the array that dogs still points to, and dogs[1]?.bark() crashes at runtime (try it in the first example). The language trades a little safety for the convenience of using Dog[] where Animal[] is expected, which is extremely common in real code. readonly Animal[] is the safe version, because it can't be written to.

  6. 6

    **strictFunctionTypes** (on in strict) 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 why WithMethod<Dog> is accepted where WithMethod<Animal> is wanted (unsound), but WithProperty<Dog> is rejected. The exception exists because Array<T>, Set<T> and many DOM types declare methods, and making them invariant would break most existing code.

  7. 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 how T is used and reports TS2636 if 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

01

Explain covariance, contravariance and invariance with an example.

02

Why are TypeScript's arrays covariant, and what goes wrong?

03

What does strictFunctionTypes change, and what does it not change?

04

What are in and out annotations for?

Practice

01

Write interface ReadonlyBox<out T> { get: () => T } and show ReadonlyBox<Dog> assignable to ReadonlyBox<Animal>. Then add a set and see the annotation fail.

02

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

03

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/out annotations 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 Dog and Animal.

  • I can say which direction applies when T comes out and when it goes in.

  • I can show the array covariance hole and the safe readonly alternative.

  • I can explain strictFunctionTypes and the method-syntax exception.

  • I can use in and out and read TS2636.