TypeScriptVariance (covariance, contravariance)

Variance: Covariance & Contravariance

Variance describes how the subtype relationship between complex types (like Array<T> or (x: T) => U) relates to the subtype relationship between their type arguments. It is one of the most important — and most misunderstood — topics in typed programming, and TypeScript's handling of it has evolved significantly over its versions.

Understanding variance will help you reason about why TypeScript allows or rejects certain assignments, especially around function types and generics.

The Animal Hierarchy

Every variance discussion starts with a simple type hierarchy. We will use animals throughout this page:

TS
class Animal {
  name: string = '';
  breathe() { return 'breathing'; }
}

class Dog extends Animal {
  breed: string = '';
  bark() { return 'woof'; }
}

class Poodle extends Dog {
  curliness: number = 0;
}

// Subtype chain: Poodle <: Dog <: Animal
// A Poodle is a Dog is an Animal — but not vice-versa
Note
A <: B means "A is a subtype of B" — every A can be used where a B is expected.
Covariance — Return Positions

A type constructor F is covariant in T when Sub <: Super implies F<Sub> <: F<Super> — the subtype relationship is preserved. Function return types and read-only containers are covariant:

TS
// Function return types are covariant
type GetDog    = () => Dog;
type GetAnimal = () => Animal;

let getDog: GetDog = () => new Dog();

// OK: if you promise to return a Dog, you satisfy () => Animal
let getAnimal: GetAnimal = getDog;

// Arrays are covariant in their element type (but see the unsoundness note below)
let dogs:    Dog[]    = [new Dog()];
let animals: Animal[] = dogs;    // OK — Dog[] is assignable to Animal[]

Covariance in return types is intuitive: if a function promises to return a Dog, it certainly satisfies a caller expecting an Animal, because every Dog is an Animal.

Contravariance — Parameter Positions

A type constructor F is contravariant in T when Sub <: Super implies F<Super> <: F<Sub> — the relationship is reversed. Function parameter types are contravariant:

TS
type HandleDog    = (d: Dog)    => void;
type HandleAnimal = (a: Animal) => void;

const handleAnimal: HandleAnimal = (a) => console.log(a.name);

// OK: a function that accepts Animal can stand in for HandleDog
// because any Dog is an Animal — the Animal handler can handle it
const handleDog: HandleDog = handleAnimal;

Why is this safe? The consumer of a HandleDog will call it with a Dog. Our handleAnimal function accepts any Animal. Since every Dog is an Animal, calling handleAnimal with a Dog is perfectly safe.

The unsafe direction is assigning a specific handler where a general handler is expected:

TS
const handleDogOnly: HandleDog = (d) => d.bark();

// UNSAFE — the caller may pass any Animal, but .bark() only exists on Dog
const handleAnimalBad: HandleAnimal = handleDogOnly;  // Error with --strictFunctionTypes

handleAnimalBad(new Animal()); // Would crash: Animal.bark is not a function
Warning
Assigning a more-specific handler (accepting Dog) to a slot expecting a more-general handler (accepting Animal) is unsafe. The caller might pass a plain Animal, which lacks Dog-specific methods.
Bivariance: TypeScript's Historical Trade-off

Before TypeScript 2.6, function parameters were bivariant — both covariant and contravariant assignments were accepted. This was intentional: strict contravariance broke many real-world patterns (especially event handler arrays). But bivariance means the type system has soundness holes.

TS
// Pre-2.6 behaviour (or with strictFunctionTypes: false):
type HandleDog    = (d: Dog)    => void;
type HandleAnimal = (a: Animal) => void;

// BOTH directions were allowed — bivariance
let a: HandleAnimal = (d: Dog) => d.bark();   // Covariant assignment — unsafe!
let b: HandleDog    = (a: Animal) => a.name;  // Contravariant assignment — safe
--strictFunctionTypes: The Correct Fix

TypeScript 2.6 introduced --strictFunctionTypes (included in --strict) which changes function-typed properties and variables to use contravariant parameter checking. Only the unsafe covariant direction is now rejected:

TS
// tsconfig.json: { "strict": true }

type HandleDog    = (d: Dog)    => void;
type HandleAnimal = (a: Animal) => void;

declare let handleDog:    HandleDog;
declare let handleAnimal: HandleAnimal;

// Contravariant (safe): Animal handler -> Dog handler slot
handleDog = handleAnimal;    // OK

// Covariant (unsafe): Dog handler -> Animal handler slot
handleAnimal = handleDog;    // Error: types of parameters are incompatible
Note
Method signatures written with shorthand syntax (handle(a: Animal): void) are still checked bivariantly for backward compatibility. Only function-property syntax (handle: (a: Animal) => void) gets strict contravariant checking.

TS
// Method shorthand — bivariant even with strictFunctionTypes
interface WithMethod {
  handle(a: Animal): void;
}

// Function property — contravariant with strictFunctionTypes
interface WithProperty {
  handle: (a: Animal) => void;
}

// This is accepted (bivariant method shorthand)
class DogHandler implements WithMethod {
  handle(d: Dog) { d.bark(); }  // OK — shorthand bypasses strict check
}

// This is rejected (strict function property)
class DogHandler2 implements WithProperty {
  handle(d: Dog) { d.bark(); }  // Error — Dog is not assignable to Animal in parameter
Invariance — Mutable Containers

A type is invariant in T when neither direction is safe — only exact matches are allowed. Mutable containers should be invariant but TypeScript's arrays are covariant (a known soundness hole):

TS
// Arrays are (unsoundly) covariant in TypeScript
let dogs:   Dog[]    = [new Dog()];
let animals: Animal[] = dogs;    // Allowed...

animals.push(new Animal());      // ...but this writes a plain Animal into a Dog[]!
// dogs[1].bark();               // Runtime crash: Animal has no bark()

// The sound alternative — ReadonlyArray is safely covariant
let readDogs:    ReadonlyArray<Dog>    = [new Dog()];
let readAnimals: ReadonlyArray<Animal> = readDogs;   // OK and safe
// readAnimals.push(new Animal());                    // Error — ReadonlyArray has no push
Warning
Prefer ReadonlyArray<T> whenever you do not need mutation. It is truly covariant and prevents the soundness hole that mutable arrays have.
Variance in Generic Interfaces

TypeScript infers variance automatically based on how each type parameter is used. A parameter appearing only in return positions is covariant; only in parameter positions is contravariant; in both is invariant.

TS
// Covariant: T only in output (return) position
interface Producer<T> {
  produce: () => T;
}

// Contravariant: T only in input (parameter) position
interface Consumer<T> {
  consume: (value: T) => void;
}

// Invariant: T in both positions
interface Transformer<T> {
  transform: (input: T) => T;
}

declare let dogProducer:    Producer<Dog>;
declare let animalProducer: Producer<Animal>;

// Covariant: Dog producer satisfies Animal producer
animalProducer = dogProducer;   // OK

declare let dogConsumer:    Consumer<Dog>;
declare let animalConsumer: Consumer<Animal>;

// Contravariant: Animal consumer satisfies Dog consumer (reversed!)
dogConsumer    = animalConsumer; // OK
animalConsumer = dogConsumer;    // Error
TypeScript 4.7: Explicit Variance Annotations

TypeScript 4.7 added in and out variance markers. These serve as documentation, catch mistakes, and can improve type-checking performance on large projects:

TS
// out = covariant (T only in output positions)
interface Producer<out T> {
  produce: () => T;
}

// in = contravariant (T only in input positions)
interface Consumer<in T> {
  consume: (value: T) => void;
}

// in out = invariant
interface Transformer<in out T> {
  transform: (input: T) => T;
}

// TypeScript will error if you violate the declared variance
interface BrokenProducer<out T> {
  consume: (value: T) => void; // Error: T is in contravariant position, but declared 'out'
}
Tip
Add in/out markers to generic interfaces in published libraries. They serve as documentation and enable the compiler to skip expensive variance re-computation.
Practical Summary Table

Variance

Rule

Position

Example

Covariant

Sub <: Super → F<Sub> <: F<Super>

Return types, read-only containers

() => Dog <: () => Animal

Contravariant

Sub <: Super → F<Super> <: F<Sub>

Parameter types

(Animal) => void <: (Dog) => void

Bivariant

Both directions allowed (unsound)

Method shorthands (legacy)

method(d: Dog) compatible with method(a: Animal)

Invariant

Only exact match allowed

Mutable generic containers (should be)

Set<Dog> not assignable to Set<Animal>

Real-World Example: Event Handlers

TS
// DOM: MouseEvent extends UIEvent extends Event
type Handler<E extends Event> = (e: E) => void;

function registerClick(handler: Handler<MouseEvent>) {
  document.addEventListener('click', handler);
}

// Contravariant: a general Event handler can satisfy a MouseEvent slot
const onEvent: Handler<Event> = (e) => console.log(e.type);
registerClick(onEvent);  // OK — Event handler handles any Event, including MouseEvent

// Covariant (unsafe): a MouseEvent handler cannot satisfy an Event slot
const onMouse: Handler<MouseEvent> = (e) => console.log(e.clientX);
const onEventBad: Handler<Event>   = onMouse;  // Error — Event may not have clientX
Key Takeaways
  1. Variance describes how subtype relationships propagate through generic types

  2. Covariant (out): subtype relationship preserved — safe for return types and read-only containers

  3. Contravariant (in): subtype relationship reversed — correct for function parameters

  4. Bivariant: both directions allowed — TypeScript's legacy method shorthand behaviour

  5. --strictFunctionTypes enforces contravariance for function-property types (included in --strict)

  6. Method shorthands remain bivariant even with --strictFunctionTypes

  7. Mutable arrays are covariant (unsound); use ReadonlyArray for type-safe covariance

  8. TypeScript 4.7 adds explicit in/out markers for documenting and enforcing variance