Chapter 11

Variance: Covariance, Contravariance, Invariance Explained

What Variance Means

Variance answers one core question: when Dog is a subtype of Animal, is Type<Dog> also a subtype of Type<Animal>?

The answer depends on how Type uses its type parameter. There are four possibilities:

Variance Definition Memory aid
Covariant Type<Dog> is assignable to Type<Animal> Same direction as the subtype (co = together)
Contravariant Type<Animal> is assignable to Type<Dog> Opposite direction (contra = against)
Invariant Neither direction is assignable Fully isolated
Bivariant Both directions are assignable TypeScript legacy behavior, unsafe
// Establish the base type hierarchy
class Animal {
  breathe() {}
}

class Dog extends Animal {
  bark() {}
}

class Cat extends Animal {
  meow() {}
}

// Dog is a subtype of Animal (more specific); assignable to Animal
const dog: Dog = new Dog();
const animal: Animal = dog; // ✓ — the basic subtype assignment rule

Covariance: Return Types Are Covariant

A function's return type is covariant — a function returning a more specific type is assignable to a function returning a broader type.

type Supplier<T> = () => T;

const getDog: Supplier<Dog> = () => new Dog();
const getAnimal: Supplier<Animal> = getDog; // ✓ covariant, legal

// Why is this safe?
// The caller of getAnimal() expects to receive an Animal.
// getDog() returns a Dog, which IS an Animal.
// The caller only uses Animal methods, and Dog has all of them.

With realistic code:

function fetchUser(): { id: number; name: string; role: string } {
  return { id: 1, name: "Alice", role: "admin" };
}

type FetchAny = () => { id: number; name: string };
const fn: FetchAny = fetchUser; // ✓ extra property in return value is fine

fn(); // caller only reads id and name; the role field is ignored

Contravariance: Function Parameters Are Contravariant

A function's parameter type is contravariant — a function accepting a broader type is assignable to a function accepting a more specific type.

type Handler<T> = (value: T) => void;

const handleAnimal: Handler<Animal> = (a) => {
  a.breathe(); // only uses Animal methods
};

const handleDog: Handler<Dog> = handleAnimal; // ✓ contravariant, legal

Why is this safe?

// handleDog's caller will pass a Dog.
handleDog(new Dog());

// After assigning handleAnimal to handleDog:
// When a Dog is passed in, handleAnimal receives it.
// Dog IS an Animal, so a.breathe() works fine. ✓

Why the reverse is unsafe:

const handleDogSpecific: Handler<Dog> = (d) => {
  d.bark(); // uses Dog-specific method
};

// ✗ If this assignment were allowed:
// const handleAnimal2: Handler<Animal> = handleDogSpecific;
// handleAnimal2(new Cat()); // pass a Cat
// → internally calls cat.bark() — Cat has no bark method → runtime crash!

// TypeScript correctly rejects this assignment:
const wrongAssign: Handler<Animal> = handleDogSpecific; // Type error

A concrete real-world scenario:

// DOM events: MouseEvent is a subtype of Event
type EventCallback<T extends Event> = (event: T) => void;

const handleAnyEvent: EventCallback<Event> = (e) => {
  console.log(e.type); // property on Event
};

const handleMouseEvent: EventCallback<MouseEvent> = (e) => {
  console.log(e.clientX, e.clientY); // MouseEvent-specific properties
};

// ✓ A handler for any Event is assignable to a MouseEvent handler slot (contravariance)
const onMouseMove: EventCallback<MouseEvent> = handleAnyEvent;

// ✗ Reverse is rejected — handleMouseEvent depends on clientX, which non-MouseEvents don't have
const onAnyEvent: EventCallback<Event> = handleMouseEvent; // Type error

Invariance: Generic Containers Are Invariant

Mutable containers like Array<T> are invariant — Array<Dog> and Array<Animal> are not assignable to each other.

const dogs: Array<Dog> = [new Dog()];

// ✗ Why can't we assign this to Array<Animal>?
const animals: Array<Animal> = dogs; // TypeScript rejects this

What invariance protects against:

// Hypothetically, if the assignment above were allowed:
const animals_hypothetical: Array<Animal> = dogs as any;

// Now we can push a Cat into what we think is Array<Animal>:
animals_hypothetical.push(new Cat());

// But dogs and animals_hypothetical reference the same array!
// dogs[1] is now a Cat, yet dogs is typed as Array<Dog>.
// dogs[1].bark() → runtime error: Cat has no bark method

Read-only containers are covariant, because writing is impossible and so pollution cannot occur:

// ReadonlyArray is covariant
const readonlyDogs: ReadonlyArray<Dog> = [new Dog()];
const readonlyAnimals: ReadonlyArray<Animal> = readonlyDogs; // ✓ legal

// ReadonlyArray has no push/pop/splice — you cannot insert a Cat
// so the contents remain safe

Bivariance: TypeScript's Legacy Problem

Method signatures written with method syntax (method(x: T)) are bivariant in TypeScript. This is a deliberate compromise for compatibility with early ES5 class patterns.

interface Processor {
  process(value: Dog): void;  // method syntax: bivariant
}

// ✓ Covariant direction (same as regular functions)
const p1: Processor = {
  process(value: Animal) { value.breathe(); }
};

// ✓ Contravariant direction — TypeScript allows this with method syntax, but it is dangerous
const p2: Processor = {
  process(value: Cat) { value.meow(); } // narrower parameter — unsafe at runtime
};

// Fix: use function property syntax for strict contravariant checking
interface StrictProcessor {
  process: (value: Dog) => void;  // function property: strict contravariance
}

Comparison:

// Method syntax: bivariant (lenient)
interface A {
  fn(x: Dog): void;
}

// Function property syntax: contravariant (strict) — prefer this
interface B {
  fn: (x: Dog) => void;
}

TypeScript 4.7 Variance Annotations

TypeScript 4.7 lets you explicitly annotate variance on type parameters. This makes constraints explicit and enables more precise checking.

// in = contravariant (T is only consumed, never produced)
// out = covariant (T is only produced, never consumed)
interface Producer<out T> {
  produce(): T;
}

interface Consumer<in T> {
  consume(value: T): void;
}

interface Transformer<in TIn, out TOut> {
  transform(value: TIn): TOut;
}

// The compiler enforces the annotations:
// - out T must not appear in parameter position (consuming position)
// - in T must not appear in return position (producing position)

// ✗ Violates constraint: out T appears in parameter position
interface WrongProducer<out T> {
  produce(): T;
  consume(value: T): void; // Error: T is marked out but used as input
}

Practical usage:

// Type-safe read-only box
interface ReadonlyBox<out T> {
  get(): T;
  // set(value: T): void; // ✗ would violate the out constraint
}

// Type-safe write-only box
interface WriteonlyBox<in T> {
  set(value: T): void;
  // get(): T; // ✗ would violate the in constraint
}

const dogBox: ReadonlyBox<Dog> = { get: () => new Dog() };
const animalBox: ReadonlyBox<Animal> = dogBox; // ✓ covariance: Dog assignable to Animal

Real Impact: The EventHandler Assignability Problem

type EventHandler<T extends Event> = (event: T) => void;

// MouseEvent is a subtype of Event.
// EventHandler<MouseEvent> is a SUPERTYPE of EventHandler<Event> (contravariance — direction flips!).

const handleEvent: EventHandler<Event> = (e) => {
  console.log(e.type);
};

const handleMouseEvent: EventHandler<MouseEvent> = (e) => {
  console.log(e.clientX); // requires MouseEvent-specific property
};

// ✓ More general handler → more specific slot (contravariance)
const onClick: EventHandler<MouseEvent> = handleEvent;

// ✗ More specific handler → general slot is rejected
const onAnything: EventHandler<Event> = handleMouseEvent; // Type error

// Practical impact in a UI framework
interface ButtonProps {
  onClick: EventHandler<MouseEvent>;   // expects a MouseEvent handler
}

// You CAN pass a general Event handler (it's contravariant-safe)
const btn: ButtonProps = { onClick: handleEvent }; // ✓

The Practical Rule

// Rule 1: T appears only in return position → covariant
interface Repository<out T> {
  findById(id: string): T;    // T produced
  findAll(): T[];             // T produced
}

// Rule 2: T appears only in parameter position → contravariant
interface Serializer<in T> {
  serialize(value: T): string; // T consumed
}

// Rule 3: T appears in both positions → invariant
interface Codec<T> {
  encode(value: T): Buffer;    // T consumed (contravariant pull)
  decode(data: Buffer): T;     // T produced (covariant pull)
  // Net result: invariant
}

declare const stringCodec: Codec<string>;
// const anyCodec: Codec<unknown> = stringCodec; // ✗ invariant — not assignable either way

Anti-Pattern: Ignoring Bivariance Until It Crashes

// ❌ Dangerous: method syntax + parameter narrowing compiles but crashes at runtime
interface AnimalShelter {
  addAnimal(animal: Animal): void; // method syntax — bivariant
}

class DogShelter implements AnimalShelter {
  private dogs: Dog[] = [];

  addAnimal(dog: Dog): void { // narrowed parameter — TypeScript doesn't catch this!
    this.dogs.push(dog);
    dog.bark(); // crashes if a Cat is passed in
  }
}

const shelter: AnimalShelter = new DogShelter();
shelter.addAnimal(new Cat()); // compiles fine, crashes at runtime

// ✓ Fix: function property syntax enforces contravariance
interface StrictAnimalShelter {
  addAnimal: (animal: Animal) => void;
}

class StrictDogShelter implements StrictAnimalShelter {
  // Must accept Animal (not just Dog) — TypeScript enforces this
  addAnimal: (animal: Animal) => void = (animal) => {
    if (animal instanceof Dog) {
      animal.bark(); // safe: checked at runtime
    }
  };
}

Comparison Table

Scenario Variance When Dog extends Animal
() => T (return type) Covariant () => Dog assignable to () => Animal
(x: T) => void (parameter) Contravariant (x: Animal) => void assignable to (x: Dog) => void
Array<T> (mutable container) Invariant Neither direction is assignable
ReadonlyArray<T> (read-only container) Covariant ReadonlyArray<Dog> assignable to ReadonlyArray<Animal>
Method syntax { fn(x: T): void } Bivariant Both directions allowed (unsafe!)
Function property { fn: (x: T) => void } Contravariant Only contravariant direction (safe)

Chapter Summary

Concept Key Rule
Covariance T only in "produce" position (return type) → same direction as subtype
Contravariance T only in "consume" position (parameter) → opposite direction
Invariance T in both positions → neither direction is assignable
Bivariance Method syntax legacy; replace with function property syntax
in / out annotations TS 4.7 — explicit variance that the compiler verifies
Practical rule Only produced → covariant; only consumed → contravariant; both → invariant

What's Next

Chapter 12 goes deeper into advanced conditional types — distributive conditional types, how Awaited<T> works under the hood, and building a compile-time type-testing framework using conditional types.

Rate this chapter
4.8  / 5  (30 ratings)

💬 Comments