Delving into type theory can sometimes feel like navigating a dense jungle of complex terminology. Yet, at its core, type variance is a fundamental concept that governs how different types relate to each other, specifically when a generic type can be substituted by another. It determines whether you can swap a ‘parent’ type for a ‘child’ type (or vice versa) without breaking your code. This guide aims to demystify covariance, contravariance, invariance, and bivariance, offering clear explanations and practical examples to serve as your quick reference.

Covariance: Child Can Replace Parent (Output)

Covariance is the most intuitive form of variance. It means that if B is a subtype of A (e.g., Cat is a subtype of Animal), then a Covariant<B> can be used where a Covariant<A> is expected. Think of it this way: you can use a Cat anywhere an Animal is expected.

Real-world analogy: If a function promises to give you an Animal, it’s perfectly fine if it actually gives you a Cat, because a Cat is an Animal. However, if a function promises a Cat, it cannot give you just any Animal, as not all animals are cats.

In code, covariance typically applies to **output positions, like return types of functions.**

// Example: Function returning a type
type Covariant<V> = () => V;

// A function returning a Cat can substitute one returning an Animal
let getAnimal: Covariant<Animal> = () => new Cat(); // OK
// A function returning an Animal cannot substitute one returning a Cat
let getCat: Covariant<Cat> = () => new Animal(); // ERROR!

Contravariance: Parent Can Replace Child (Input)

Contravariance is often trickier to grasp as it’s the inverse of covariance. Here, if B is a subtype of A, then a Contravariant<A> can be used where a Contravariant<B> is expected. In simpler terms: you can use a more general ‘parent’ type where a more specific ‘child’ type is required.

Real-world analogy: Imagine you have a food processor designed for CatFood (which is a specific type of AnimalFood). You also have a general AnimalFood processor. You can use the general AnimalFood processor to process CatFood (it will simply add protein, which is fine for a cat). However, you cannot use the CatFood processor for any AnimalFood, because it might add fishy flavoring that other animals wouldn’t like.

In code, contravariance usually applies to **input positions, such as function parameters.**

// Example: Function taking a type as input
type Contravariant<V> = (v: V) => void;

// A processor for Animal food can be used where a Cat food processor is expected.
let processCatFood: Contravariant<Cat> = (cat) => console.log('Processing cat food generally');
let processAnimalFood: Contravariant<Animal> = (animal) => console.log('Processing animal food with protein');

processCatFood = processAnimalFood; // OK! A general animal processor can handle cat food.
processAnimalFood = processCatFood; // ERROR! A cat-specific processor can't handle ALL animal food.

Invariance: No Substitutions Allowed (Input & Output)

Invariance signifies a complete lack of substitutability. If a type is invariant, then Invariant<A> can only be replaced by Invariant<A>, even if B is a subtype of A.

Real-world analogy: Consider waste sorting. You have specific bins for FoodWaste and general Waste. You cannot put FoodWaste into a general bin if the general bin is specifically for non-recyclable items (even though FoodWaste is a type of Waste). Similarly, you can’t put unsorted Waste into a FoodWaste bin. The types must match exactly.

In programming, invariance occurs when a type appears in both input and output positions, or when strict type matching is enforced. Many languages, especially those with nominative type systems, default to invariance.

// Example: Type appearing in both input and output
type Invariant<V> = (v: V) => V;

// Invariant types cannot be substituted, even by subtypes/supertypes
let transformAnimal: Invariant<Animal> = (a) => a;
let transformCat: Invariant<Cat> = (c) => c;

transformAnimal = transformCat; // ERROR! Not interchangeable.
transformCat = transformAnimal; // ERROR! Not interchangeable.

Bivariance: Complete Interchangeability

Bivariance is the opposite of invariance, implying full interchangeability between a type and its subtype. If B is a subtype of A, then Bivariant<A> can be replaced by Bivariant<B>, and Bivariant<B> can be replaced by Bivariant<A>.

In TypeScript, method parameters are a notable example of bivariance. While regular function parameters are contravariant (as discussed), method parameters historically exhibited bivariant behavior for flexibility, though it’s theoretically less sound and can lead to subtle bugs. Modern TypeScript allows explicit variance annotations to control this.

// Example: Method parameters in TypeScript
type Bivariant<V> = {
    process(v: V): void;
};

let animalProcessor: Bivariant<Animal> = { process: (a) => console.log('Processing animal via method') };
let catProcessor: Bivariant<Cat> = { process: (c) => console.log('Processing cat via method') };

animalProcessor = catProcessor; // OK (due to bivariance of methods)
catProcessor = animalProcessor; // OK (due to bivariance of methods)

// Using explicit 'in' keyword for strict contravariance in methods:
type StrictContravariantMethod<in V> = {
    process(v: V): void;
};

let strictAnimalProcessor: StrictContravariantMethod<Animal> = { process: (a) => console.log('Strict animal method') };
let strictCatProcessor: StrictContravariantMethod<Cat> = { process: (c) => console.log('Strict cat method') };

strictAnimalProcessor = strictCatProcessor; // ERROR! Now strictly contravariant.
strictCatProcessor = strictAnimalProcessor; // OK!

Quick Reference Cheat Sheet

Here’s a concise summary of the different variance types:

Variance Rule (Subtype B vs Supertype A) Position Implication
Covariance B can replace A (e.g., Cat for Animal) Output/Return A function returning B can substitute one returning A. (() => B -> () => A)
Contravariance A can replace B (e.g., Animal for Cat) Input/Parameter A function accepting A can substitute one accepting B. ((arg: A) => void -> (arg: B) => void)
Invariance A can only replace A Both Input & Output No substitution allowed; types must match exactly. ((arg: A) => A)
Bivariance A <-> B (interchangeable) Input (Methods in TS) B can replace A, and A can replace B. ({ method(arg: T) })

Understanding these concepts is crucial for writing robust and type-safe code, especially when working with generics and higher-order functions in languages like TypeScript.

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.
You need to agree with the terms to proceed