Variance and function type compatibility

TypeScript · Advanced · 14 min

Variance explains many puzzling assignability errors and the few places where TypeScript is deliberately unsound.

If Dog is assignable to Animal, is Container<Dog> assignable to Container<Animal>? It depends on how the type parameter is used — its variance.

  • Covariant (output positions): a () => Dog is usable as () => Animal. Producing something more specific is safe. Readonly properties and return types are covariant.
  • Contravariant (input positions): a (a: Animal) => void is usable as (d: Dog) => void, but not the other way round. A handler that accepts any animal can certainly handle a dog; a dog-only handler can't be trusted with a cat.
  • Invariant: a type used for both input and output (a mutable Box<T> with get and set) is only compatible with exactly the same T.

With strictFunctionTypes (part of strict), function-typed properties get proper contravariant parameter checks. Two deliberate exceptions remain for practicality:

  • Method syntax is bivariant. Parameters of members declared as methods (handle(x: Dog): void) are checked in both directions; members declared as properties (handle: (x: Dog) => void) are checked strictly. Prefer property syntax for callbacks in your own interfaces when you want the strict check.
  • Arrays are covariant even though they're mutable, so const animals: Animal[] = dogs; animals.push(cat) compiles and corrupts dogs. Use readonly arrays in parameters to make this honest.

The compiler normally measures variance from a type's structure. You can annotate it explicitly with in (contravariant), out (covariant) or in out (invariant) on type parameters: interface Producer<out T> { make(): T }. These annotations mostly serve as documentation and can speed up checks on large recursive types; they should match what the structure implies, not try to change it.

Example

interface Animal { name: string }
interface Dog extends Animal { bark(): void }
interface Cat extends Animal { meow(): void }
const dog: Dog = { name: "Rex", bark() {} };

// Covariance: producers of Dog are producers of Animal
const makeDog: () => Dog = () => dog;
const makeAnimal: () => Animal = makeDog;          // ok
// Contravariance: consumers of Animal can consume Dog
const logAnimal = (a: Animal) => console.log(a.name);
const onDog: (d: Dog) => void = logAnimal;         // ok
const dogOnly = (d: Dog) => d.bark();
// @ts-expect-error -- a Dog-only handler can't accept every Animal
const onAnimal: (a: Animal) => void = dogOnly;
// Method syntax is bivariant (unsound, but allowed)
interface Handlers { handle(a: Animal): void }
const h: Handlers = { handle: dogOnly };           // compiles!
// Arrays are covariant (also unsound)
const dogs: Dog[] = [dog];
const animals: Animal[] = dogs;
animals.push({ name: "Tom", meow() {} } as Cat);   // dogs now contains a cat

interface Producer<out T> { make(): T }
const p: Producer<Animal> = { make: makeDog };
onDog(dog); makeAnimal(); h.handle(dog); p.make(); void onAnimal;

Remember

  • Outputs are covariant, inputs contravariant, read-write positions invariant
  • strictFunctionTypes checks function-typed properties, not methods
  • Arrays and method parameters are deliberately unsound (bivariant/covariant)
  • in/out annotate variance explicitly on type parameters

Watch out for

  • Declaring callbacks with method syntax in interfaces and losing the strict parameter check.

Check yourself

  1. With strict on, is (d: Dog) => void assignable to (a: Animal) => void?
  2. interface I { m(x: Dog): void } and const o: I = { m: (a: Animal) => {} }. And the reverse: interface J { m(x: Animal): void } with { m: (d: Dog) => {} }. Which compile?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.typescriptlang.org/docs/handbook/type-compatibility.html