Variance, Covariance, and Contravariance
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.
Think of variance as one-way streets for type relationships. If Dog extends Animal:
- Covariant (output/producer):
Producer<Dog>is assignable toProducer<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 toConsumer<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
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 rejectedMethod 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:
- Performance: The compiler skips variance analysis and trusts your annotation — faster compilation on complex generics.
- Documentation: Makes the intended usage clear —
Producer<out T>immediately communicates that T flows out. - Correctness: If you annotate
out Tbut 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 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
| What developers do | What 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 |
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.
- 1Covariant (out): subtyping goes the same direction — return types, readonly properties, Producer
<Dog>assignable to Producer<Animal> - 2Contravariant (in): subtyping goes the opposite direction — function parameters, Consumer
<Animal>assignable to Consumer<Dog> - 3Invariant (in out): neither direction — types that both produce and consume T must match exactly
- 4Use property syntax (handler: (x: T) => void) for contravariant function types — method shorthand is bivariant
- 5Use readonly arrays in function parameters to prevent the mutable array covariance unsoundness