TypeScript Covariance and Contravariance
Covariance and contravariance are important concepts in TypeScript's type system; understanding them helps you write type-safe code.
They describe how generic types behave in parent-child type relationships.
SVG Diagram: Covariance and Contravariance
Why Do We Need Covariance and Contravariance?
When using generic classes or functions, the behavior of type parameters is not as simple as you might think.
Assigning Dog to Animal is safe, but assigning a function that handles Animal to a function that handles Dog may not be safe.
Covariance and contravariance rules help TypeScript catch these potential type errors.
Concepts:Covariance allows subtypes to be converted to supertypes, contravariance allows supertypes to be converted to subtypes, and invariance does not allow conversion in either direction.
Covariant
Covariance means a subtype can be assigned to a supertype. This is safe for output types (such as function return values).
Example
class Animal {
name: string = "Animal";
}
class Dog extends Animal {
breed: string = "Rural Dog";
}
// Covariance: output types can be converted to broader types
// A function returning Dog can be assigned to a function returning Animal
type AnimalGetter = () => Animal;
type DogGetter = () => Dog;
// Dog is a subtype of Animal, so DogGetter can be assigned to AnimalGetter
const getDog: DogGetter = () => new Dog();
const getAnimal: AnimalGetter = getDog; // Covariance: safe
// Run
const animal: Animal = getAnimal();
console.log("Animal name: " + animal.name);
Output:
Animal Name: Animal
Return type covariance:It is safe for a function to return a more specific subtype, because the returned object always satisfies the requirements of the supertype.
Contravariant
Contravariance means that for input types (such as function parameters), a supertype can be assigned to a subtype.
Example
class Animal {
name: string = "Animal";
}
class Dog extends Animal {
breed: string = "Rural Dog";
}
// Contravariance: input types can be converted to more specific types
// A function accepting Animal can be assigned to a function accepting Dog
type DogConsumer = (dog: Dog) => void;
type AnimalConsumer = (animal: Animal) => void;
// If a function accepting a broader type can be assigned to a function accepting a more specific type
// then when we pass a Dog, the function might not be able to handle it (it lacks Dog-specific properties)
const consumeAnimal: AnimalConsumer = (animal) => {
console.log("Processing animal: " + animal.name);
};
const consumeDog: DogConsumer = consumeAnimal; // Contravariance: safe
// Run
const dog = new Dog();
dog.breed = "Husky";
consumeDog(dog);
Parameter contravariance:Function parameters use contravariance because a function that accepts a more specific type cannot handle a broader type.
Enabling Strict Function Types
TypeScript checks function parameters contravariantly by default. Enabling strictFunctionTypes enforces this rule.
Example
interface Animal {
readonly name: string;
}
interface Dog extends Animal {
readonly breed: string;
}
// Define function types
type GetName = (animal: Animal) => string;
type GetDogBreed = (dog: Dog) => string;
// Correct assignment
const getDogBreed: GetDogBreed = (dog) => dog.breed;
// Attempt to assign - will error under strictFunctionTypes
// Because AnimalConsumer (broader parameter) cannot be assigned to DogConsumer (more specific parameter)
// This is because parameters are contravariant
function printAnimalName(animal: Animal): string {
return animal.name;
}
// Try to assign a function that accepts a broader type to a more specific type
// const getSpecific: GetDogBreed = printAnimalName; // Error!
console.log("Dog breed: " + getDogBreed({ name: "Wangcai", breed: "Husky" }));
strictFunctionTypes:Enable this option in tsconfig.json for stricter type checking.
Covariance of Generic Classes
Properties of generic classes are covariant by default.
Example
class Animal {
name: string = "Animal";
}
class Dog extends Animal {
breed: string = "Dog";
}
// Generic container class
class Cage<T> {
animal: T;
constructor(animal: T) {
this.animal = animal;
}
}
// Covariance: a subtype container can be assigned to a supertype container
const dogCage = new Cage(new Dog());
const animalCage: Cage<Animal> = dogCage; // Covariance: safe
// animalCage can now be safely used as a cage containing animals
console.log("Animal name: " + animalCage.animal.name);
Property covariance:Object properties are covariant; a subtype property can be assigned to a supertype property.
Array Covariance
Arrays in TypeScript are covariant, but you need to be aware of issues caused by mutability.
Example
class Animal {
name: string = "Animal";
}
class Dog extends Animal {
breed: string = "Dog";
}
// Array covariance
const dogs: Dog[] = [
{ name: "Wangcai", breed: "Husky" },
{ name: "Xiao Bai", breed: "Samoyed" }
];
// Dog[] can be assigned to Animal[]
const animals: Animal[] = dogs; // Covariance: safe
// Problem: although it's type-safe, you can actually add other animals
// animals.push({ name: "Cat", breed: "Cat" }); // May cause problems at runtime!
console.log("Number of animals: " + animals.length);
Array mutability:Modifying an array after a covariant assignment may cause runtime errors; be careful.
Using extends for Safe Assignment
After understanding covariance and contravariance, you can safely design generic interfaces.
Example
interface Producer<T> {
// Producer method: return value is covariant
produce(): T;
}
interface Consumer<T> {
// Consumer method: parameters are contravariant
consume(value: T): void;
}
// Concrete implementation
class DogProducer implements Producer<Dog> {
produce(): Dog {
return { name: "Wangcai", breed: "Husky" };
}
}
class AnimalConsumer implements Consumer<Animal> {
consume(animal: Animal): void {
console.log("Consuming animal: " + animal.name);
}
}
// Producer<Dog> can be assigned to Producer<Animal> (covariant)
const animalProducer: Producer<Animal> = new DogProducer();
// Consumer<Animal> can be assigned to Consumer<Dog> (contravariant)
const dogConsumer: Consumer<Dog> = new AnimalConsumer();
// Test
const animal = animalProducer.produce();
console.log("Producing: " + animal.name);
dogConsumer.consume({ name: "Wangcai", breed: "Husky" });
Design principles:Choose the appropriate type direction based on the purpose of the method to improve the type safety of your API.
Important Notes
- Return type covariance:It is safe for functions to return a subtype
- Parameter contravariance:It is safe for function parameters to use a supertype
- Enable strict mode:Use strictFunctionTypes for stricter checking
- Array covariance:Be aware of potential issues caused by mutability
Best practices:Understanding covariance and contravariance helps design more type-safe APIs and avoid runtime errors.
Summary
Covariance and contravariance are core concepts of the TypeScript type system.
- Covariance:Subtype → supertype, used for output types
- Contravariance:Parent type → child type, used for input types
- Invariance:Cannot be assigned to each other
- strictFunctionTypes:Enable strict function type checking
Other extensionsRecommendation:Consider covariance and contravariance when designing generic APIs to write safer type code.