TypeScript Variance: Covariance and Contravariance
Variance describes how subtyping relationships between types transfer to their generic containers. When you have a type hierarchy like `Labrador extends Dog extends Animal`, it's intuitive that you...
Key Insights
- Variance determines how subtyping relationships transfer to generic types—covariance preserves the direction (Dog[] is assignable to Animal[]), while contravariance reverses it (a function accepting Animal is assignable to one accepting Dog)
- TypeScript’s default bivariance for methods creates type safety holes; enable
strictFunctionTypesto enforce contravariance for function parameters and catch potential runtime errors - Understanding variance is critical for writing type-safe generic functions, React components, and event handlers—the wrong variance can allow invalid data to slip through your type system
Introduction to Variance
Variance describes how subtyping relationships between types transfer to their generic containers. When you have a type hierarchy like Labrador extends Dog extends Animal, it’s intuitive that you can use a Labrador wherever a Dog is expected. But what happens when these types are wrapped in arrays, functions, or other generic types? Can you use Dog[] where Animal[] is expected? What about functions that accept or return these types?
These questions matter because getting variance wrong leads to runtime errors that TypeScript should prevent. The type system needs rules for when Container<Dog> can substitute for Container<Animal>, and these rules vary depending on whether the type appears in input positions (contravariant), output positions (covariant), or both (invariant).
Let’s establish a basic type hierarchy we’ll use throughout:
class Animal {
name: string;
constructor(name: string) {
this.name = name;
}
}
class Dog extends Animal {
bark() {
console.log('Woof!');
}
}
class Labrador extends Dog {
color: 'yellow' | 'black' | 'chocolate';
constructor(name: string, color: 'yellow' | 'black' | 'chocolate') {
super(name);
this.color = color;
}
}
With this hierarchy, Labrador is a subtype of Dog, which is a subtype of Animal. The question is: how does this relationship transfer to generic types?
Covariance Explained
Covariance means the subtyping relationship is preserved. If Dog is a subtype of Animal, then Container<Dog> is a subtype of Container<Animal>. This works safely when the type appears in output positions—when you’re reading values out of the container.
TypeScript arrays are covariant, which allows this assignment:
const dogs: Dog[] = [new Dog('Rex'), new Dog('Max')];
const animals: Animal[] = dogs; // Valid: arrays are covariant
// Safe operations - we're only reading
animals.forEach(animal => console.log(animal.name));
This seems convenient, but it creates a soundness hole:
const dogs: Dog[] = [new Dog('Rex')];
const animals: Animal[] = dogs;
// TypeScript allows this, but it's unsafe!
animals.push(new Animal('Generic Animal'));
// Now dogs[1] is an Animal, not a Dog
dogs[1].bark(); // Runtime error: bark is undefined
Arrays are covariant because it’s practical—the alternative would make arrays much harder to use. TypeScript chooses pragmatism over perfect soundness here.
Function return types are also covariant, which is sound:
type AnimalGetter = () => Animal;
type DogGetter = () => Dog;
const getDog: DogGetter = () => new Dog('Buddy');
const getAnimal: AnimalGetter = getDog; // Valid and safe
const animal = getAnimal(); // Returns a Dog, which is an Animal
console.log(animal.name); // Always safe
This works because if you expect a function that returns an Animal, getting a Dog is always safe—Dog has everything Animal has.
Read-only properties also demonstrate safe covariance:
interface ReadonlyBox<out T> {
readonly value: T;
}
const dogBox: ReadonlyBox<Dog> = { value: new Dog('Charlie') };
const animalBox: ReadonlyBox<Animal> = dogBox; // Safe: covariant position
console.log(animalBox.value.name); // Safe to read
The out modifier explicitly marks the type parameter as covariant, documenting that T only appears in output positions.
Contravariance Explained
Contravariance reverses the subtyping relationship. If Dog is a subtype of Animal, then Container<Animal> is a subtype of Container<Dog>. This applies to function parameters—input positions.
Consider a function that processes dogs:
type DogHandler = (dog: Dog) => void;
type AnimalHandler = (animal: Animal) => void;
const handleAnimal: AnimalHandler = (animal) => {
console.log(animal.name);
};
const handleDog: DogHandler = handleAnimal; // Valid: contravariant
This is safe because if you need a function that handles Dogs, a function that handles any Animal works—it won’t try to access Dog-specific properties. The reverse would be unsafe:
const handleDog: DogHandler = (dog) => {
dog.bark(); // Assumes dog has bark method
};
// This would be unsafe if TypeScript allowed it
const handleAnimal: AnimalHandler = handleDog; // Error with strictFunctionTypes
If this were allowed, you could pass any Animal to handleAnimal, but the function expects Dog-specific methods.
Event handlers demonstrate contravariance in practice:
interface DogEvent {
target: Dog;
}
interface AnimalEvent {
target: Animal;
}
type DogEventHandler = (event: DogEvent) => void;
const handleAnimalEvent = (event: AnimalEvent) => {
console.log(event.target.name);
};
// Valid: function accepting Animal can handle Dog events
const dogHandler: DogEventHandler = handleAnimalEvent;
The strictFunctionTypes compiler option enforces this contravariance. Without it, TypeScript uses bivariance for function parameters, which is unsound but was necessary for backward compatibility.
Bivariance and Type Safety Issues
Bivariance means a type is both covariant and contravariant—the worst of both worlds for type safety. TypeScript historically used bivariance for method parameters, creating significant soundness holes.
Consider this example without strictFunctionTypes:
// With strictFunctionTypes: false (default in older TypeScript)
interface AnimalHandler {
handle(animal: Animal): void;
}
interface DogHandler {
handle(dog: Dog): void;
}
const dogHandler: DogHandler = {
handle(dog: Dog) {
dog.bark(); // Assumes dog has bark
}
};
// Bivariance allows this unsafe assignment
const animalHandler: AnimalHandler = dogHandler;
animalHandler.handle(new Animal('Generic')); // Runtime error!
The syntax you use matters. Method syntax (shown above) is bivariant, but property syntax with function types is contravariant:
interface DogHandler {
handle: (dog: Dog) => void; // Property syntax
}
const dogHandler: DogHandler = {
handle: (dog: Dog) => dog.bark()
};
// Error with strictFunctionTypes: true
const animalHandler: AnimalHandler = dogHandler;
Enable strictFunctionTypes in your tsconfig.json:
{
"compilerOptions": {
"strictFunctionTypes": true,
"strict": true
}
}
The strict flag includes strictFunctionTypes, so using strict mode gives you proper contravariance checking. This catches errors at compile time that would otherwise manifest as runtime failures.
Practical Applications
Understanding variance is essential for writing type-safe generic code. Generic constraints need careful variance consideration:
interface Mapper<in TInput, out TOutput> {
map(input: TInput): TOutput;
}
// TInput is contravariant (in), TOutput is covariant (out)
const animalToString: Mapper<Animal, string> = {
map: (animal) => animal.name
};
const dogToString: Mapper<Dog, string> = animalToString; // Valid
React event handlers demonstrate variance in real applications:
interface ButtonProps {
onClick: (event: MouseEvent) => void;
}
// More general handler works for specific events
const handleUIEvent = (event: UIEvent) => {
console.log('Event triggered');
};
const Button: React.FC<ButtonProps> = ({ onClick }) => {
return <button onClick={onClick}>Click me</button>;
};
// Valid: contravariant parameter
<Button onClick={handleUIEvent} />
API response mapping requires understanding covariance:
interface ApiResponse<out T> {
data: T;
status: number;
}
function processAnimalResponse(response: ApiResponse<Animal>) {
console.log(response.data.name);
}
const dogResponse: ApiResponse<Dog> = {
data: new Dog('API Dog'),
status: 200
};
processAnimalResponse(dogResponse); // Valid: covariant type parameter
Common Pitfalls and Best Practices
The most common mistake is assuming arrays are invariant and being surprised by runtime errors:
// Pitfall: Mutating covariant arrays
const dogs: Dog[] = [new Dog('Rex')];
const animals: Animal[] = dogs;
animals.push(new Animal('Cat')); // Compiles but wrong!
// Solution: Use readonly for covariance without mutation
const dogs: readonly Dog[] = [new Dog('Rex')];
const animals: readonly Animal[] = dogs; // Safe
// animals.push(...) // Error: push doesn't exist on readonly
Avoid unsafe type assertions that bypass variance checks:
// Dangerous: Casting away variance
const handleDog = (dog: Dog) => dog.bark();
const handleAnimal = handleDog as (animal: Animal) => void; // Unsafe!
// Better: Write properly typed functions
const handleAnimal = (animal: Animal) => {
if (animal instanceof Dog) {
animal.bark();
}
};
For generic functions, use variance annotations to document intent:
// Good: Explicit variance
interface Producer<out T> {
produce(): T;
}
interface Consumer<in T> {
consume(value: T): void;
}
// Clear that T is only produced, never consumed
function mapProducer<T, U>(
producer: Producer<T>,
mapper: (value: T) => U
): Producer<U> {
return {
produce: () => mapper(producer.produce())
};
}
Always enable strictFunctionTypes. The minor inconvenience of stricter checking prevents entire classes of runtime errors. If you’re working with callbacks, event handlers, or generic types, variance determines whether your code is truly type-safe or just appears to be. Understanding these concepts transforms TypeScript from a type annotation system into a genuine safety tool.