Skip to main content

In TypeScript, variance concepts (covariance, contravariance, and invariance) refer to how the relationship between types changes when they are used in different contexts, particularly with generic types. Understanding these concepts is essential when working with complex generic systems, as they influence how types can be assigned to each other in different scenarios. Let's break down each one:

1. Covariance

Covariance refers to the scenario where you can replace a type A with a subtype B in a return type or in outgoing positions (e.g., when you're reading data from a structure). This happens when the type preserves the subtype relationship in a way that you can safely use a more specific type instead of a more general one.

  • In simpler terms: "If type B is a subtype of type A, you can use B where A is expected."
  • Common use case: This usually occurs with function return types.

Example:

class Animal {}
class Dog extends Animal {}

function getAnimal(): Animal {
  return new Animal();
}

const getDog: () => Dog = getAnimal; // Covariance works here.

Here, Dog is a subtype of Animal, so you can assign getAnimal() (which returns Animal) to getDog (which expects a function returning Dog). This is valid because you're using it in an outgoing position (i.e., returning from a function).

2. Contravariance

Contravariance refers to the scenario where you can replace a type A with a supertype B in an incoming position (e.g., when you're passing data into a structure). Essentially, it means the type system allows a more general type where a more specific type is expected.

  • In simpler terms: "If type B is a supertype of type A, you can use A where B is expected."
  • Common use case: This typically occurs with function parameters.

Example:

class Animal {}
class Dog extends Animal {}

function handleAnimal(animal: Animal): void {
  console.log(animal);
}

const handleDog: (dog: Dog) => void = handleAnimal; // Contravariance works here.

Here, Dog is a subtype of Animal, so you can use handleAnimal (which expects Animal) in a place where handleDog (which expects Dog) is required. This is valid because you're using it in an incoming position (i.e., as a function parameter).

3. Bidirectional Covariance

Bidirectional covariance occurs when a type is covariant in some contexts and contravariant in others. In other words, the type relationship can vary depending on whether you're working with input or output positions.

  • In simpler terms: "In one case, the type is covariant (outgoing), and in another case, it's contravariant (incoming)."

Example:

class Animal {}
class Dog extends Animal {}

interface Handler<T> {
  handleAnimal: (animal: T) => void;  // Contravariant position (input)
  getAnimal: () => T;  // Covariant position (output)
}

const dogHandler: Handler<Dog> = {
  handleAnimal: (dog) => console.log(dog), // Contravariance (expects a more general type)
  getAnimal: () => new Dog() // Covariance (returns a more specific type)
};

Here, the handleAnimal method takes an argument, so it is contravariant with respect to T. The getAnimal method returns a value, so it is covariant.

4. Invariance

Invariance means that the type relationship is strictly preserved and cannot be changed in any direction. In other words, if a generic type is invariant, you cannot substitute it with any subtype or supertype.

  • In simpler terms: "If T is invariant, T must always be exactly the type specified and cannot be changed."
  • Common use case: In variance-sensitive structures like Array<T>.

Example:

class Animal {}
class Dog extends Animal {}

const animalArray: Array<Animal> = [new Animal()];
const dogArray: Array<Dog> = [new Dog()];

// The following assignment is **not allowed** because Array<T> is invariant:
animalArray = dogArray; // Error: Type 'Array<Dog>' is not assignable to type 'Array<Animal>'.

Array<T> is invariant because the type of T cannot be substituted with a subtype or supertype. You can't assign Array<Dog> to a variable of type Array<Animal>, even though Dog is a subtype of Animal.


Summary

  • Covariance: You can substitute a type with its subtype (outgoing positions like return types).
  • Contravariance: You can substitute a type with its supertype (incoming positions like parameters).
  • Bidirectional Covariance: A combination of covariance and contravariance, depending on whether you're reading or writing.
  • Invariance: The type cannot be changed in either direction (it must be exactly the type specified).

Understanding these concepts allows you to write more flexible and powerful TypeScript code, especially when dealing with generics and type relationships.

Does this help clarify how variance works in TypeScript?