Skip to content
TR

Phase 7 · Advanced and tooling · Lesson 7.1

Advanced

Variance

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

VarianceRuleTypical position of T
CovariantF<Dog> <: F<Animal> (same direction)output: return types, readonly properties
ContravariantF<Animal> <: F<Dog> (flipped)input: function parameters
Invariantneither directionboth input and output
Bivariantboth directionsTypeScript'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 covariant

A 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 B

The 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 function

dogs 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 3

Invariance: 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?

  1. Performance. For big, deeply recursive generic types, measuring variance can be expensive. An annotation lets the checker skip the measurement.
  2. 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.
  3. Documentation. out T states 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: T field is effectively covariant in TypeScript (property writes are not checked contravariantly), another deliberate soundness trade-off.

Recap

  • Variance describes how Dog <: Animal carries over to F<Dog> and F<Animal>.
  • Covariant: outputs (return types, readonly arrays). Contravariant: function parameters. Invariant: both. Bivariant: both directions allowed.
  • strictFunctionTypes (in strict) 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 out annotations (TS 4.7) declare variance; the compiler verifies them. They help performance and circular types, not semantics.

Interview cards

1 / 7