TypeScript Covariance and Contravariance |
Covariance and contravariance are important concepts in the TypeScript type system, and understanding them helps write type-safe code.
They describe the behavior of generic types in parent-child type relationships.
Why Covariance and Contravariance Are Needed
When using generic classes or functions, the behavior of type parameters is not as simple as you might imagine.
Assigning a Dog to an Animal is safe, but assigning a function that handles Animals to a function that handles Dogs may not be safe.
The rules of covariance and contravariance help TypeScript catch these potential type errors.
Concept: Covariance allows subtypes to be converted to parent types, contravariance allows parent types to be converted to subtypes, and invariance does not allow conversion in either direction.
Covariance
Covariance means that a subtype can be assigned to a parent type. For output types (like function return values), this is safe.
Example
// Define types for animals and dogs
class Animal {
name: string ="animal";
}
class Dog extends Animal {
breed: string ="farm 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 subclass 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 Result:
Animal name: animal
Return Value Covariance: It's safe for a function to return a more specific subtype because the returned object necessarily meets the requirements of the parent type.
Contravariance
Contravariance means that for input types (like function parameters), a parent type can be assigned to a child type.
Example
// Define types
class Animal {
name: string ="animal";
}
class Dog extends Animal {
breed: string ="farm 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 handle it (missing 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 accepting a more specific type cannot handle a more general type.
Enabling Strict Function Types
TypeScript defaults to performing contravariance checks on function parameters. Enabling strictFunctionTypes enforces this rule.
Example
// Define types
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 assignment - will error under strictFunctionTypes
// Because AnimalConsumer (broader parameter) cannot be assigned to DogConsumer (more specific parameter)
// This is due to parameters being contravariant
function printAnimalName(animal: Animal): string {
return animal.name;
}
// Try to assign a function accepting a broader type to one expecting a more specific type
// const getSpecific: GetDogBreed = printAnimalName; // Error!
console.log("Breed: "+ getDogBreed({ name:"Wangcai", breed:"Husky"}));
strictFunctionTypes: Enable this option in
tsconfig.jsonfor stricter type checking.
Covariance in Generic Classes
Properties of generic classes are covariant by default.
Example
// Define types
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 container of a subtype can be assigned to a container of a parent type
const dogCage = new Cage(new Dog());
const animalCage: Cage<Animal>= dogCage; // Covariance: safe
// animalCage can now safely be used as a cage containing animals
console.log("Animal name: "+ animalCage.animal.name);
Property Covariance: Object properties are covariant β a property of a subtype can be assigned to a property of a parent type.
Array Covariance
In TypeScript, arrays are covariant, but attention must be paid to issues arising from mutability.
Example
// Define types
class Animal {
name: string ="animal";
}
class Dog extends Animal {
breed: string ="dog";
}
// Array covariance
const dogs: Dog[]=[
{ name:"Wangcai", breed:"Husky"},
{ name:"Xiaobai", breed:"Samoyed"}
];
// Dog[] can be assigned to Animal[]
const animals: Animal[]= dogs; // Covariance: safe
// Issue: although type-safe, you can actually add other animals
// animals.push({ name: "Cat", breed: "Cat" }); // Could cause runtime issues!
console.log("Number of animals: "+ animals.length);
Array Mutability: Modifying an array after a covariant assignment could lead to runtime errors β be cautious.
Using extends for Safe Assignment
After understanding covariance and contravariance, you can design generic interfaces safely.
Example
// Define types
interface Producer<T>{
// Production method: return value is covariant
produce(): T;
}
interface Consumer<T>{
// Consumption method: parameter is 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> (covariance)
const animalProducer: Producer<Animal>= new DogProducer();
// Consumer<Animal> can be assigned to Consumer<Dog> (contravariance)
const dogConsumer: Consumer<Dog>= new AnimalConsumer();
// Test
const animal = animalProducer.produce();
console.log("Produced: "+ animal.name);
dogConsumer.consume({ name:"Wangcai", breed:"Husky"});
Design Principle: Choose appropriate type directions based on the purpose of methods to improve API type safety.
Notes
- Return Value Covariance: Returning a subtype from a function is safe
- Parameter Contravariance: Using a parent type for function parameters is safe
- Enable Strict Mode: Use
strictFunctionTypesfor stricter checks - Array Covariance: Be aware of potential issues caused by mutability
Best Practice: Understanding covariance and contravariance helps design more type-safe APIs and avoid runtime errors.
Summary
Covariance and contravariance are core concepts in the TypeScript type system.
- Covariance: Subtype β Parent type, used for output types
- Contravariance: Parent type β Subtype, used for input types
- Invariance: Cannot be assigned to each other
- strictFunctionTypes: Enables strict function type checking
Suggestion: When designing generic APIs, consider covariance and contravariance to write safer type code.
YouTip