TypeScript Covariance and Contravariance Without the Theory: A Practical Guide to Generic Type Safety
Most type safety breakdowns in TypeScript stem from misunderstanding variance. Learn how covariance and contravariance protect your codebase from runtime failures without the academic jargon.
Most type safety breakdowns in TypeScript stem from misunderstanding variance. Teams add generic constraints, enable strict mode, and still ship bugs where Dog[] gets passed to a function expecting Animal[] and corrupts state. The runtime error comes from a type system that approved the assignment. The problem is not the generics. The problem is developers treating variance as theory instead of a mechanism that determines which generic type substitutions are safe.
flowchart LR
A("Developer assigns Dog[] to Animal[]") --> B("TypeScript allows the assignment") --> C("Runtime corrupts state")
style C stroke:#ef4444,fill:#450a0a,color:#fca5a5
Variance describes how subtype relationships flow through generic types. When Dog extends Animal, covariance means Array<Dog> also extends Array<Animal>. Contravariance means the relationship reverses for function parameters. Get this wrong and the compiler allows assignments that break at runtime. Get this right and generic types enforce safety automatically.
flowchart LR
A("Developer assigns Dog[] to Animal[]") --> B("Variance rules block unsafe writes") --> C("TypeScript prevents the corruption") --> D("Runtime stays safe")
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Key Takeaways
- Covariance allows subtypes to flow in the same direction (if
Dog extends Animal, thenArray<Dog> extends Array<Animal>), which works safely for read-only structures but breaks with mutable collections. - Contravariance reverses subtype relationships for function parameters (a handler accepting
Animalcan substitute for one acceptingDogbecause broader inputs are always safe), protecting against type narrowing errors. - TypeScript infers variance from structure: arrays and promises are covariant, function parameters are contravariant, and mutable properties must be invariant to prevent corruption.
- Variance annotations (
outfor covariance,infor contravariance) make generic constraints explicit and prevent accidental misuse in custom types. - The most common variance bug is treating mutable arrays as covariant, which allows writes that violate type constraints and corrupt data at runtime.
Covariance Explained: When Subtypes Flow in the Same Direction
Covariance preserves subtype relationships through a generic container. When Dog extends Animal, covariance means Container<Dog> extends Container<Animal>. This feels intuitive for read-only structures. If a function reads animals from a container, passing a container of dogs works because every dog is an animal.
flowchart TD
A("Animal (base type)") --> B("Dog extends Animal")
C("Container<Animal>") --> D("Container<Dog> (covariant)")
B -.-> D
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Arrays in TypeScript are covariant. This design choice prioritizes convenience for read operations but creates a trap for mutations. When a function declares animals: Animal[], TypeScript allows passing Dog[] because arrays are covariant. Reading works fine. Writing breaks the type system.
class Animal {
name: string;
constructor(name: string) {
this.name = name;
}
}
class Dog extends Animal {
bark() {
console.log('woof');
}
}
class Cat extends Animal {
meow() {
console.log('meow');
}
}
function processAnimals(animals: Animal[]): void {
// Reading is safe with covariance
animals.forEach(animal => console.log(animal.name));
// Writing violates the original type constraint
animals.push(new Cat('Whiskers')); // Compiles but corrupts the array
}
const dogs: Dog[] = [new Dog('Buddy'), new Dog('Max')];
processAnimals(dogs); // TypeScript allows this due to covariance
dogs[2].bark(); // Runtime error: meow is not a functionThe failure mode here is subtle but expensive. The compiler approves the assignment because arrays are covariant. The function receives a Dog[] typed as Animal[], then pushes a Cat. The original array now contains a cat in a position where the caller expects only dogs. When the caller invokes bark(), the runtime crashes.
This matters because covariance trades type safety for ergonomics. Read-only operations justify the tradeoff. Mutations do not. The solution is to recognize when covariance applies and prevent writes through readonly types or immutable patterns.
Contravariance in Function Parameters: The Direction Reversal
Contravariance reverses subtype relationships for function parameters. When Dog extends Animal, contravariance means a function accepting Animal can substitute for a function accepting Dog. The relationship flips because accepting broader types is always safe, while accepting narrower types creates holes.
flowchart TD
A("Handler accepting Dog") --> B("Handler accepting Animal (contravariant)")
C("Dog extends Animal") -.-> B
D("Broader input = safer substitution")
B --> D
style B stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This reversal protects against type narrowing errors. If a system expects a handler for dogs and you provide a handler for animals, every dog passed to the handler is also an animal. The handler works correctly. If you provide a handler for poodles (a subtype of Dog), passing a non-poodle dog breaks the handler because it expects properties that do not exist.
type AnimalHandler = (animal: Animal) => void;
type DogHandler = (dog: Dog) => void;
const handleAnimal: AnimalHandler = (animal) => {
console.log(`Processing ${animal.name}`);
};
const handleDog: DogHandler = (dog) => {
console.log(`Walking ${dog.name}`);
dog.bark();
};
function registerDogHandler(handler: DogHandler): void {
const dog = new Dog('Buddy');
handler(dog);
}
// Contravariance: broader parameter types can substitute
registerDogHandler(handleAnimal); // Safe: Animal handler accepts all Dogs
// This would be unsafe if allowed (covariance for parameters)
// registerDogHandler(handlePoodle); // Error: narrower types cannot substituteThe implication here is that parameter contravariance prevents runtime method-not-found errors. When TypeScript enforces strictFunctionTypes, function parameters become contravariant by default. This strictness catches substitution bugs that older TypeScript versions allowed. Teams that disable strict mode or use method syntax (which remains bivariant for compatibility) lose this protection.
Contravariance applies to callbacks, event handlers, and any function passed as a parameter. The pattern is consistent: broader input types can always substitute for narrower input types because the function receives at least what it expects. The failure occurs when teams expect covariance and pass handlers that are too specific.
Practical Example: Building Type-Safe Event Handlers
Event systems demonstrate variance tradeoffs in real-world code. A typical event bus accepts handlers for specific event types, then dispatches events to matching handlers. Without variance constraints, the system allows handlers that expect properties the events do not have.
interface Event {
type: string;
timestamp: number;
}
interface ClickEvent extends Event {
type: 'click';
x: number;
y: number;
}
interface KeyEvent extends Event {
type: 'keypress';
key: string;
}
class EventBus<T extends Event> {
private handlers: Array<(event: T) => void> = [];
subscribe(handler: (event: T) => void): void {
this.handlers.push(handler);
}
emit(event: T): void {
this.handlers.forEach(handler => handler(event));
}
}
// Create buses with specific event types
const clickBus = new EventBus<ClickEvent>();
const keyBus = new EventBus<KeyEvent>();
// Contravariance allows broader handlers
const logAllEvents = (event: Event) => {
console.log(`Event at ${event.timestamp}`);
};
clickBus.subscribe(logAllEvents); // Safe: Event handler accepts ClickEvent
keyBus.subscribe(logAllEvents); // Safe: Event handler accepts KeyEvent
// Covariance would be unsafe for handlers
const handleClick = (event: ClickEvent) => {
console.log(`Click at (${event.x}, ${event.y})`);
};
keyBus.subscribe(handleClick); // Error: ClickEvent handler cannot handle KeyEventThis pattern shows why contravariance matters for callbacks. The event bus stores handlers typed to its event constraint. When a handler accepts the base Event type, it works for any specific event because every specific event is also a base event. When a handler expects ClickEvent, it cannot handle KeyEvent because keypress events lack coordinates.
The type system prevents the second subscription because function parameters are contravariant. If parameters were covariant, the compiler would allow handleClick for keyBus, and the runtime would crash when handleClick accesses event.x on a keypress event. Contravariance blocks this entire class of bugs.
Array and Promise Variance: Where Covariance Shows Up Daily
Arrays and promises are covariant in TypeScript because their primary use case is reading values. This covariance makes code ergonomic but requires discipline to avoid mutation bugs.
Arrays are covariant because they extend ReadonlyArray<T>, which is safely covariant. The compiler allows assigning Dog[] to Animal[] because reading dogs as animals works correctly. Writing breaks down when functions mutate the array with incompatible types.
flowchart LR
A("Function receives Dog[]") --> B("Covariance allows Animal[] assignment") --> C("Function pushes Cat") --> D("Caller's Dog[] now contains Cat")
style D stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
Promises are covariant because they resolve to values, not accept them. When Promise<Dog> resolves, the consumer receives a dog. Since Dog extends Animal, a consumer expecting Promise<Animal> can safely handle a Promise<Dog>. The resolved value is always at least an animal.
async function fetchDog(): Promise<Dog> {
return new Dog('Buddy');
}
async function processAnimal(animalPromise: Promise<Animal>): Promise<void> {
const animal = await animalPromise;
console.log(animal.name); // Safe: Dog has name property
}
// Covariance: Promise<Dog> extends Promise<Animal>
processAnimal(fetchDog()); // No error, resolves correctlyThe distinction is critical. Promises produce values, so covariance flows from subtype to supertype safely. Arrays expose mutation methods, so covariance creates holes unless the array is truly readonly. The pattern that prevents array bugs is to use ReadonlyArray<T> or readonly T[] for parameters that should not mutate.
function safeProcessAnimals(animals: readonly Animal[]): void {
animals.forEach(animal => console.log(animal.name)); // Reading is safe
// animals.push(new Cat('Whiskers')); // Error: push does not exist on readonly array
}
const dogs: Dog[] = [new Dog('Buddy'), new Dog('Max')];
safeProcessAnimals(dogs); // Safe: no mutation possibleThis matters because most array-related variance bugs come from treating mutable arrays as if they were readonly. When a function signature declares Animal[], developers assume it only reads. When the function mutates, the type system allows corruption. Using readonly forces the contract explicit and blocks mutations at compile time.
Variance Annotations vs Implicit Variance: When to Use Each
TypeScript infers variance from how a generic type uses its parameter. Arrays that read values are covariant. Functions that accept parameters are contravariant. Mutable properties must be invariant because both reads and writes occur. These rules work for built-in types but custom generics need explicit variance annotations to communicate intent.
flowchart LR
subgraph Implicit["Implicit Variance"]
A("TypeScript infers from usage")
B("Works for built-in types")
C("Can be ambiguous for custom generics")
end
subgraph Explicit["Explicit Annotations"]
D("out T: covariant")
E("in T: contravariant")
F("No annotation: invariant")
end
A --> D
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Variance annotations use out for covariant parameters and in for contravariant parameters. When a generic type only produces values (read-only), mark it out. When a generic type only consumes values (write-only), mark it in. When both occur, leave it invariant with no annotation.
// Covariant: only produces T values
interface Producer<out T> {
get(): T;
}
// Contravariant: only consumes T values
interface Consumer<in T> {
accept(value: T): void;
}
// Invariant: both produces and consumes T
interface Storage<T> {
get(): T;
set(value: T): void;
}
class AnimalProducer implements Producer<Animal> {
get(): Animal {
return new Animal('Generic');
}
}
class DogProducer implements Producer<Dog> {
get(): Dog {
return new Dog('Buddy');
}
}
// Covariance: Producer<Dog> extends Producer<Animal>
const animalProducer: Producer<Animal> = new DogProducer(); // Safe
const animal = animalProducer.get(); // Returns Dog typed as AnimalThe implication here is that explicit variance annotations prevent accidental invariance. Without the out annotation, Producer<T> would be invariant by default, blocking the assignment of DogProducer to Producer<Animal>. The annotation tells TypeScript that T only appears in output positions, making covariance safe.
Contravariance works the same way for input positions:
class AnimalConsumer implements Consumer<Animal> {
accept(value: Animal): void {
console.log(`Accepting ${value.name}`);
}
}
class DogConsumer implements Consumer<Dog> {
accept(value: Dog): void {
console.log(`Accepting dog ${value.name}`);
value.bark();
}
}
// Contravariance: Consumer<Animal> extends Consumer<Dog>
const dogConsumer: Consumer<Dog> = new AnimalConsumer(); // Safe: broader type
dogConsumer.accept(new Dog('Max')); // Works: AnimalConsumer accepts all DogsThe failure mode without annotations is silent invariance. Teams write interfaces that should be covariant or contravariant, but TypeScript treats them as invariant because it cannot prove variance from structure alone. The result is unnecessary type errors when substituting compatible types. Variance annotations fix this by declaring intent explicitly.
Common Variance Bugs and How to Catch Them
The most common variance bug is treating mutable arrays as covariant. Developers pass Dog[] to a function accepting Animal[], then the function mutates the array with incompatible types. The compiler allows this because arrays are covariant, but the runtime crashes when the caller accesses dog-specific methods on the mutated elements.
flowchart LR
A("Function receives Dog[] as Animal[]") --> B("Function mutates with Cat") --> C("Caller accesses bark() on Cat") --> D("Runtime crashes")
style D stroke:#ef4444,fill:#450a0a,color:#fca5a5
The fix is to use readonly arrays for function parameters that should not mutate:
// Unsafe: allows mutation
function unsafeProcess(animals: Animal[]): void {
animals.push(new Cat('Whiskers')); // Compiles, corrupts caller's array
}
// Safe: prevents mutation
function safeProcess(animals: readonly Animal[]): void {
animals.forEach(animal => console.log(animal.name));
// animals.push(new Cat('Whiskers')); // Error: push does not exist
}
const dogs: Dog[] = [new Dog('Buddy')];
safeProcess(dogs); // Safe: no corruption possibleThe second common bug is assigning handlers with narrow parameter types to systems expecting broader types. This occurs when teams treat function parameters as covariant instead of contravariant. The compiler catches this under strictFunctionTypes, but without strict mode, the bug compiles and crashes at runtime.
interface EventHandler<T> {
handle(event: T): void;
}
class ClickHandler implements EventHandler<ClickEvent> {
handle(event: ClickEvent): void {
console.log(`Click at (${event.x}, ${event.y})`);
}
}
// This should fail but compiles without strictFunctionTypes
const keyHandler: EventHandler<KeyEvent> = new ClickHandler(); // Unsafe
keyHandler.handle({ type: 'keypress', timestamp: Date.now(), key: 'A' }); // CrashesThe fix is to enable strictFunctionTypes in tsconfig.json:
{
"compilerOptions": {
"strict": true, // Includes strictFunctionTypes
"strictFunctionTypes": true // Or enable explicitly
}
}With strict mode, TypeScript rejects the unsafe assignment because ClickEvent is narrower than KeyEvent. The contravariance rule blocks the substitution and the bug never compiles.
The third bug is using bivariant methods instead of contravariant functions. Method syntax in classes and interfaces is bivariant for backward compatibility, allowing both covariant and contravariant assignments. This flexibility breaks type safety for callbacks.
interface Callback<T> {
// Bivariant method: unsafe
invoke(value: T): void;
}
// Use function property for contravariance
interface SafeCallback<T> {
invoke: (value: T) => void; // Contravariant under strict mode
}The pattern that catches these bugs early is to enable strict mode, use readonly arrays for parameters, and prefer function properties over methods for callbacks. These three rules eliminate most variance-related runtime errors.
Frequently Asked Questions
What is the difference between covariance and contravariance in TypeScript?
Covariance means subtype relationships flow in the same direction through a generic type (if Dog extends Animal, then Array<Dog> extends Array<Animal>). Contravariance means the relationship reverses for function parameters (a handler accepting Animal can substitute for one accepting Dog because broader inputs are always safe).
Why are arrays covariant in TypeScript if it allows unsafe mutations?
Arrays are covariant because their primary use case is reading elements, and covariance makes read operations ergonomic. The tradeoff is that mutations can corrupt type safety. Using readonly arrays for function parameters that should not mutate prevents this corruption while preserving the convenience of covariance for read-only operations.
When should I use variance annotations like out and in?
Use variance annotations when writing custom generic types to make variance explicit and prevent accidental invariance. Mark a type parameter out T when the type only produces values (read-only), in T when it only consumes values (write-only), and omit annotations when both occur (invariant). Annotations prevent substitution errors and communicate intent to other developers.
How does strictFunctionTypes affect variance in TypeScript?
Enabling strictFunctionTypes makes function parameters contravariant by default, preventing unsafe assignments where a handler expecting a narrow type substitutes for one expecting a broader type. Without strict mode, parameters are bivariant (both covariant and contravariant), which compiles but crashes at runtime when narrow handlers receive broader inputs. Strict mode catches these bugs at compile time.
What is the most common variance bug developers encounter?
The most common bug is passing a mutable array to a function that mutates it with incompatible types. Because arrays are covariant, TypeScript allows assigning Dog[] to Animal[], but if the function pushes a Cat, the original array is corrupted. Using readonly arrays for parameters that should not mutate prevents this entire class of bugs.
Making Variance Work for You
Variance is not theory. Variance is the mechanism that determines which generic type substitutions compile and which crash at runtime. When a team ships a bug where Dog[] corrupts state after passing through a function expecting Animal[], the root cause is covariance rules that allowed an unsafe write. When a handler crashes because it expects properties the event does not have, the root cause is missing contravariance on parameters.
The fix is to recognize variance patterns in your own code. Use readonly arrays for parameters that should not mutate. Enable strictFunctionTypes to enforce contravariant parameters. Add variance annotations to custom generics that only read or only write. These three practices eliminate most variance bugs before they reach production.
That covers the essential patterns for variance in TypeScript. Apply these in production and the difference will be immediate. The type system will block the substitutions that break at runtime, and your codebase will stop shipping bugs that the compiler should have caught.