YouTip LogoYouTip

Ts Covariance

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.json for 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 strictFunctionTypes for 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.

← Ts ReferencesTs Indexed Types β†’