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.