🔷 TypeScript Q55 / 101

Explain covariance and contravariance.

AI-Powered Answer ✓ Answered

Variance describes how subtyping relationships between types propagate to subtyping relationships between more complex types constructed from them, such as generics or function types. In TypeScript, understanding variance is crucial for writing robust and type-safe code, especially when dealing with functions and callback patterns.

Understanding Variance

Imagine you have two types, A and B, where A is a subtype of B (A extends B). Variance determines whether a type constructor F (e.g., Array<T>, (T) => U) maintains, reverses, or invalidates this subtyping relationship when applied. There are four main types of variance:

  • Covariance: Subtyping relationship is preserved. If A extends B, then F<A> extends F<B>.
  • Contravariance: Subtyping relationship is reversed. If A extends B, then F<B> extends F<A>.
  • Bivariance: Subtyping relationship holds in both directions (A extends B implies F<A> extends F<B> AND F<B> extends F<A>). This is generally unsound but sometimes used for practicality.
  • Invariance: No subtyping relationship holds. If A extends B, F<A> is neither a subtype nor a supertype of F<B>.

Covariance

A type parameter or position is covariant if the subtyping relationship is preserved. This typically applies to 'read-only' positions, such as the return type of a function or elements within an immutable array. If you can read a 'Bird', you can also read an 'Animal' because a Bird *is an* Animal.

In TypeScript, function return types are covariant. If a function that returns a Bird is assignable to a function that returns an Animal, it is covariant because Bird is a subtype of Animal.

typescript
interface Animal { name: string; }
interface Bird extends Animal { wingspan: number; }
interface Dog extends Animal { bark(): void; }

// A function that returns a Bird
type GetBird = () => Bird;

// A function that returns an Animal
type GetAnimal = () => Animal;

// Covariance: GetBird is assignable to GetAnimal because Bird extends Animal.
// You can safely use a function returning a more specific type (Bird)
// where a function returning a less specific type (Animal) is expected.
let getBirdFunc: GetBird = () => ({ name: "Sparrow", wingspan: 20 });
let getAnimalFunc: GetAnimal = getBirdFunc; // OK: Covariant return type

console.log(getAnimalFunc().name); // "Sparrow"
// console.log(getAnimalFunc().wingspan); // Error: Property 'wingspan' does not exist on type 'Animal'

Contravariance

A type parameter or position is contravariant if the subtyping relationship is reversed. This typically applies to 'write-only' positions, such as function parameters. If you need to accept an 'Animal' (supertype), you can also safely accept a 'Bird' (subtype) because you can perform 'Animal' operations on it. Conversely, if you need to accept a 'Bird', you cannot safely accept an 'Animal' because an 'Animal' might not be a 'Bird'.

In TypeScript, function parameter types, when strictFunctionTypes is enabled, are contravariant. If a function that accepts an Animal is assignable to a function that accepts a Bird, it is contravariant because Animal is a supertype of Bird.

typescript
interface Animal { name: string; move(): void; }
interface Bird extends Animal { fly(): void; }

class GenericAnimal implements Animal {
    constructor(public name: string) {}
    move() { console.log(`${this.name} moves.`); }
}

class Eagle extends GenericAnimal implements Bird {
    constructor() { super('Eagle'); }
    fly() { console.log(`${this.name} flies high.`); }
}

// A function that processes an Animal
type ProcessAnimal = (animal: Animal) => void;

// A function that processes a Bird
type ProcessBird = (bird: Bird) => void;

// Contravariance: ProcessAnimal is assignable to ProcessBird because Animal is a supertype of Bird.
// You can safely use a function accepting a less specific type (Animal)
// where a function accepting a more specific type (Bird) is expected.
let processAnimalFunc: ProcessAnimal = (animal) => console.log(`Processing animal: ${animal.name}`);
let processBirdFunc: ProcessBird = processAnimalFunc; // OK: Contravariant parameter type (with strictFunctionTypes)

processBirdFunc(new Eagle()); // This is safe. The Animal processor can handle an Eagle.

// If we tried to do the reverse:
// let processAnimalFunc2: ProcessAnimal = processBirdFunc; // Error (with strictFunctionTypes)
// This would be unsafe: processBirdFunc expects a Bird, but processAnimalFunc2 might be called with any Animal (e.g., a Dog).

Bivariance (TypeScript's Default for Function Parameters without `strictFunctionTypes`)

Bivariance means that a type parameter is both covariant and contravariant. This is generally unsound from a type-safety perspective, as it allows for assignments that could lead to runtime errors. TypeScript's function parameter types are bivariant by default when strictFunctionTypes is false.

The reason for this 'leniency' is often practical, allowing common JavaScript patterns like event handlers (where a generic handler might be assigned to a more specific event type or vice-versa) to work without excessive type casting. However, it's generally recommended to enable strictFunctionTypes for better type safety, which enforces contravariance for function parameters.

typescript
// With strictFunctionTypes: false (default for older projects or specific needs)
interface Animal { name: string; }
interface Bird extends Animal { wingspan: number; }
interface Dog extends Animal { bark(): void; }

type Handler<T> = (data: T) => void;

let handleAnimal: Handler<Animal> = (animal) => console.log(`Handling animal: ${animal.name}`);
let handleBird: Handler<Bird> = (bird) => console.log(`Handling bird: ${bird.name}, wingspan: ${bird.wingspan}`);

// Bivariance allows both of these (unsafe) assignments if strictFunctionTypes is false:
handleBird = handleAnimal; // UNSAFE! handleBird now expects a Bird, but handleAnimal can only process generic Animal properties
// If handleBird is later called with a Bird, handleAnimal is invoked, and it won't crash,
// but if handleAnimal was expecting specific Bird properties, it would be an issue.

handleAnimal = handleBird; // UNSAFE! handleAnimal now expects any Animal, but handleBird expects a Bird.
// If handleAnimal is later called with a Dog, handleBird is invoked on a Dog, which doesn't have `wingspan`, leading to runtime error.

Invariance

Invariance means that a type parameter allows no subtyping relationship. If A extends B, then F<A> is neither a subtype nor a supertype of F<B>. This is the strictest form of variance and generally the safest, as it prevents any potential misuse due to subtyping assumptions.

In TypeScript, mutable array types (Array<T> or T[]) are effectively invariant for write operations, meaning Array<Bird> is not assignable to Array<Animal> because you could push a Dog into Array<Animal>, which would then become an Array<Bird> that contains a Dog.

typescript
interface Animal { name: string; }
interface Bird extends Animal { wingspan: number; }
interface Dog extends Animal { bark(): void; }

let birds: Bird[] = [
    { name: "Eagle", wingspan: 200 }
];

let animals: Animal[] = [
    { name: "Lion" }
];

// Invariance for mutable arrays:
// birds = animals; // Error: Type 'Animal[]' is not assignable to type 'Bird[]'.
                   // An array of Animals could contain non-Birds (e.g., Dogs).

// animals = birds; // Error: Type 'Bird[]' is not assignable to type 'Animal[]'.
                   // This one is actually allowed in TypeScript by default (Array is covariant for read access)
                   // but pushing a Dog to 'animals' would then make 'birds' contain a Dog, which is unsound for writing.
                   // For true invariance (blocking both ways), you'd need a more complex type or `ReadonlyArray`.

// TypeScript's `ReadonlyArray<T>` is covariant, meaning `ReadonlyArray<Bird>` IS assignable to `ReadonlyArray<Animal>`.
// This is safe because you can only read from it, never write.
let readonlyBirds: ReadonlyArray<Bird> = [
    { name: "Falcon", wingspan: 110 }
];

let readonlyAnimals: ReadonlyArray<Animal> = readonlyBirds; // OK: ReadonlyArray is covariant

// readonlyAnimals[0].wingspan; // Error: Property 'wingspan' does not exist on type 'Animal'

Summary and Key Takeaways

  • Covariance: Subtype relationship preserved (A extends B -> F<A> extends F<B>). Common in 'read' positions like function return types or elements of ReadonlyArray<T>.
  • Contravariance: Subtype relationship reversed (A extends B -> F<B> extends F<A>). Common in 'write' positions like function parameters (when strictFunctionTypes is enabled).
  • Bivariance: Both covariant and contravariant. Unsound, but TypeScript uses it for function parameters by default (without strictFunctionTypes) for practicality.
  • Invariance: No subtyping relationship. Safest, but most restrictive. Mutable arrays in TypeScript exhibit aspects of invariance for type safety during modification.
  • Best Practice: Always enable strictFunctionTypes in your tsconfig.json to enforce contravariance for function parameters, leading to much stronger type safety for callbacks and higher-order functions.