Skip to content

Variance, Covariance, and Contravariance

advanced13 min read

The Subtyping Direction Problem

Quick: if Dog extends Animal, can you use Dog[] where Animal[] is expected? What about (x: Dog) => void where (x: Animal) => void is expected? Most developers get at least one of these wrong. The answer depends on variance — the rules that determine how subtyping relationships propagate through generic types. Get this wrong and you introduce unsound type assignments that crash at runtime.

Mental Model

Think of variance as one-way streets for type relationships. If Dog extends Animal:

  • Covariant (output/producer): Producer<Dog> is assignable to Producer<Animal> — the subtyping goes the SAME direction. Like a dog kennel can be used where an animal shelter is expected.
  • Contravariant (input/consumer): Consumer<Animal> is assignable to Consumer<Dog> — the subtyping goes the OPPOSITE direction. A vet who handles all animals can be used where a dog vet is expected, but not vice versa.
  • Invariant: Neither direction works — the types must match exactly.

Covariance — Same Direction

A type is covariant in a position when subtyping is preserved in the same direction:

class Animal { name: string = ""; }
class Dog extends Animal { breed: string = ""; }
class Cat extends Animal { indoor: boolean = false; }

// Return types are COVARIANT
// If Dog extends Animal, then () => Dog extends () => Animal
type AnimalFactory = () => Animal;
type DogFactory = () => Dog;

const getDog: DogFactory = () => new Dog();
const getAnimal: AnimalFactory = getDog; // OK — covariant
// Safe: if you expect an Animal and get a Dog, Dog has everything Animal has

Arrays are covariant in TypeScript (readonly arrays are safely covariant):

const dogs: Dog[] = [new Dog()];
const animals: readonly Animal[] = dogs; // OK — reading Dog as Animal is safe

Contravariance — Opposite Direction

This is the one that trips everyone up. A type is contravariant in a position when subtyping goes the opposite direction:

// Function parameters are CONTRAVARIANT (with strictFunctionTypes)
type AnimalHandler = (animal: Animal) => void;
type DogHandler = (dog: Dog) => void;

const handleAnimal: AnimalHandler = (a) => console.log(a.name);
const handleDog: DogHandler = handleAnimal; // OK — contravariant
// Safe: handleAnimal accepts any Animal. Dog is an Animal, so it works.

const handleDogOnly: DogHandler = (d) => console.log(d.breed);
const handleAnimal2: AnimalHandler = handleDogOnly; // ERROR
// Unsafe: handleDogOnly reads .breed, but not every Animal has .breed
Common Trap

Without strictFunctionTypes, TypeScript treats function parameters as bivariant (both directions allowed). This is unsound:

// With strictFunctionTypes: false (UNSOUND)
const dogHandler: DogHandler = (d) => console.log(d.breed);
const animalHandler: AnimalHandler = dogHandler; // Allowed — but wrong!
animalHandler(new Cat()); // Runtime crash — Cat doesn't have .breed

// With strictFunctionTypes: true (SOUND)
const animalHandler: AnimalHandler = dogHandler; // ERROR — correctly rejected

Method shorthand in interfaces (method(x: T): void) is always bivariant for legacy reasons. Only function property syntax (method: (x: T) => void) is correctly contravariant. Use property syntax for type safety.

The in and out Modifiers (TypeScript 4.7+)

TypeScript 4.7 added explicit variance annotations for generic type parameters:

// out = covariant (output/producer position)
interface Producer<out T> {
  get(): T;
}

// in = contravariant (input/consumer position)
interface Consumer<in T> {
  accept(value: T): void;
}

// in out = invariant (both positions)
interface Transform<in out T> {
  transform(value: T): T;
}
Why explicit variance annotations exist

Before in/out, TypeScript inferred variance by analyzing how type parameters are used. This analysis is correct but slow for complex types — the compiler has to traverse the entire type structure.

Explicit annotations serve two purposes:

  1. Performance: The compiler skips variance analysis and trusts your annotation — faster compilation on complex generics.
  2. Documentation: Makes the intended usage clear — Producer<out T> immediately communicates that T flows out.
  3. Correctness: If you annotate out T but use T in an input position, TypeScript errors. This catches design mistakes.
// Error: Type 'T' is not assignable to 'out' position
interface Broken<out T> {
  accept(value: T): void; // ERROR — T is in an 'in' position
  get(): T;               // OK — T is in an 'out' position
}

Variance in Practice

Now let's see where variance actually bites you in real code.

Array Mutability and Variance

// Readonly arrays are safely covariant
const dogs: readonly Dog[] = [new Dog()];
const animals: readonly Animal[] = dogs; // Safe — can only read

// Mutable arrays SHOULD be invariant (but TypeScript makes them covariant)
const dogs2: Dog[] = [new Dog()];
const animals2: Animal[] = dogs2; // TypeScript allows this (unsound!)
animals2.push(new Cat()); // No type error, but dogs2 now contains a Cat!
console.log(dogs2[1].breed); // Runtime crash — Cat doesn't have breed
TypeScript's array unsoundness

TypeScript intentionally makes mutable arrays covariant for practical ergonomics, even though it's technically unsound. This is a known trade-off — strict invariance would make arrays too painful to use. Be aware that passing a Dog[] where Animal[] is expected and then mutating through the Animal[] reference can introduce type errors at runtime.

Generic Class Variance

// Covariant container — only produces T
class Box<out T> {
  constructor(private value: T) {}
  get(): T { return this.value; }
}

const dogBox: Box<Dog> = new Box(new Dog());
const animalBox: Box<Animal> = dogBox; // OK — covariant

// Contravariant handler — only consumes T
class Validator<in T> {
  constructor(private validate: (value: T) => boolean) {}
  check(value: T): boolean { return this.validate(value); }
}

const animalValidator = new Validator<Animal>((a) => a.name.length > 0);
const dogValidator: Validator<Dog> = animalValidator; // OK — contravariant

// Invariant — both produces and consumes T
class State<in out T> {
  constructor(private value: T) {}
  get(): T { return this.value; }
  set(value: T) { this.value = value; }
}

const dogState: State<Dog> = new State(new Dog());
// const animalState: State<Animal> = dogState; // ERROR — invariant

Production Scenario: Event Emitter Variance

// Type-safe event emitter with correct variance
type EventMap = Record<string, unknown>;

// Listener is contravariant in its parameter
type Listener<T> = (data: T) => void;

class TypedEmitter<Events extends EventMap> {
  private handlers = new Map<string, Set<Function>>();

  // T is in an 'in' (contravariant) position for the listener parameter
  on<K extends keyof Events>(event: K, listener: Listener<Events[K]>): void {
    const set = this.handlers.get(event as string) ?? new Set();
    set.add(listener);
    this.handlers.set(event as string, set);
  }

  // T is in an 'in' (contravariant) position for the data parameter
  emit<K extends keyof Events>(event: K, data: Events[K]): void {
    this.handlers.get(event as string)?.forEach(fn => fn(data));
  }
}

interface AppEvents {
  userLogin: { userId: string; timestamp: number };
  error: { message: string; code: number };
}

const emitter = new TypedEmitter<AppEvents>();

// Listener receives exactly the right type
emitter.on("userLogin", (data) => {
  console.log(data.userId); // string — fully typed
});

emitter.emit("error", { message: "fail", code: 500 }); // Type-checked
Execution Trace
Declare
emitter: TypedEmitter`<AppEvents>`
Events bound to AppEvents interface
on()
K = 'userLogin', Events[K] = { userId: string; timestamp: number }
Listener parameter type resolved from event key
Contra
Listener<{ userId: string; timestamp: number }>
Listener is contravariant — can accept a handler for a wider type
emit()
K = 'error', data must be { message: string; code: number }
Data parameter type enforced from event key
What developers doWhat they should do
Using method shorthand in interfaces for type-safe callbacks
Method shorthand is bivariant (unsound). Property syntax respects strictFunctionTypes and is correctly contravariant.
Use property syntax: handler: (x: T) => void, not handler(x: T): void
Passing mutable typed arrays to functions that might push incompatible elements
Mutable arrays are unsoundly covariant in TypeScript. Readonly prevents mutation through the wider reference.
Use readonly arrays in parameter types: (items: readonly Animal[]) => ...
Forgetting that function parameters are contravariant, not covariant
Parameters flow IN — a more general handler safely accepts specific types, but a specific handler can't handle all general types
A handler for (Animal) => void can be used where (Dog) => void is needed — not the reverse
Not using in/out variance annotations on complex generic types
Explicit annotations skip variance inference (faster) and document which direction T flows
Add in/out to clarify intent and improve compilation speed on large generics
Quiz
If Dog extends Animal, which assignment is type-safe?
Quiz
Why is method shorthand bivariant while property syntax is contravariant?
Quiz
What does the 'out' variance annotation mean on a type parameter?

Challenge: Fix the Variance Bug

// This code has a variance-related bug that compiles but crashes at runtime.
// Find the bug, explain why it happens, and fix it.

class AnimalShelter {
  private animals: Animal[] = [];

  add(animal: Animal) {
    this.animals.push(animal);
  }

  getAll(): Animal[] {
    return this.animals;
  }
}

class DogShelter extends AnimalShelter {
  getDogs(): Dog[] {
    return this.getAll() as Dog[];
  }
}

function addCat(shelter: AnimalShelter) {
  shelter.add(new Cat());
}

const dogShelter = new DogShelter();
dogShelter.add(new Dog());
addCat(dogShelter); // Passes type check — DogShelter extends AnimalShelter
const dogs = dogShelter.getDogs();
console.log(dogs[1].breed); // Runtime crash — dogs[1] is a Cat!

The bug is that DogShelter extends AnimalShelter and is passed to addCat(), which adds a Cat to what's supposed to be a dogs-only collection. The getDogs() cast as Dog[] then lies about the contents.

// Fix: Make DogShelter not extend AnimalShelter, or use generics with proper constraints
class Shelter<T extends Animal> {
  private animals: T[] = [];

  add(animal: T) {
    this.animals.push(animal);
  }

  getAll(): readonly T[] {
    return this.animals;
  }
}

const dogShelter = new Shelter<Dog>();
dogShelter.add(new Dog());
// addCat(dogShelter); // ERROR: Shelter<Dog> is not assignable to Shelter<Animal>
// Because Shelter has T in both in and out positions — it's invariant

// If you need a read-only view:
function readAnimals(shelter: { getAll(): readonly Animal[] }) {
  return shelter.getAll(); // Covariant — safe for reading
}
readAnimals(dogShelter); // OK — only reads, never adds

The fix uses generics where T appears in both input (add) and output (getAll) positions, making Shelter invariant in T. This prevents the unsound substitution.

Key Rules
  1. 1Covariant (out): subtyping goes the same direction — return types, readonly properties, Producer<Dog> assignable to Producer<Animal>
  2. 2Contravariant (in): subtyping goes the opposite direction — function parameters, Consumer<Animal> assignable to Consumer<Dog>
  3. 3Invariant (in out): neither direction — types that both produce and consume T must match exactly
  4. 4Use property syntax (handler: (x: T) => void) for contravariant function types — method shorthand is bivariant
  5. 5Use readonly arrays in function parameters to prevent the mutable array covariance unsoundness