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 Background Title Covariance and Contravariance Concepts Covariant Covariant Output type: safe Dog → Animal Provider<Dog> → Provider<Animal> Invariant Invariant Input/output types: strict Cannot be assigned to each other Consumer<Dog> ≠ Consumer<Animal> Contravariant Contravariant Input type: reversed Animal → Dog Consumer<Animal> → Consumer<Dog> Bottom section: rules TypeScript default behavior Rule 1 Return type: covariant Rule 2 Parameters: contravariant (strictFunctionTypes) Rule 3 Properties: covariant Arrow markers

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

// Define the Animal and Dog types
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

// Define types
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

// 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 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

// 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 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

// Define types
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

// Define types
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

Recommendation:Consider covariance and contravariance when designing generic APIs to write safer type code.

Other extensions