Variance: Why Your Function Types Surprise You
An array of dogs is assignable to an array of animals, right up until it sinks your program. Covariance, contravariance, and the bivariant hole TypeScript leaves open in method parameters, shown with the assignments that do and do not type-check.
You write Dog[] and pass it where Animal[] is expected. The compiler waves it through. Then somewhere downstream a function calls .push(someCat) on the array it received, and now your Dog[] holds a Cat. No cast, no any, no warning. The assignment that felt obviously correct is the one that broke you.
Variance is the set of rules that decides which of these “obviously correct” assignments the compiler accepts. Most of the time the rules match intuition. The two places they don’t, array writes and method parameters, are where the surprises live. Both are deliberate holes in TypeScript’s soundness.
Three directions
Start with a subtype relationship: Dog <: Animal (every Dog is an Animal). A type constructor F<T> is covariant if it preserves that direction, contravariant if it reverses it, and invariant if it demands an exact match.
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
// Covariant position: reading. Dog <: Animal => Dog[] <: Animal[] (for reads)
declare const dogs: Dog[];
const animals: Animal[] = dogs; // OK, every element you read out is an Animal
// Contravariant position: a function that consumes T.
// A handler that accepts any Animal is usable where an Animal handler is wanted,
// AND where a Dog handler is wanted. Direction reverses.
type Handler<T> = (x: T) => void;
declare const animalHandler: Handler<Animal>;
const dogHandler: Handler<Dog> = animalHandler; // OK, accepting more is safe
The covariant case reads naturally. The contravariant case is the one people fight. A function that handles any Animal is a valid Dog handler, because it copes with every Dog you throw at it and then some. Widening what a function accepts is always safe for its callers.
Return types up, parameters down
Function types combine both directions at once. Return types are covariant, parameters are contravariant. The full rule: (a: A) => R is assignable to (b: B) => S when B <: A (parameters flip) and R <: S (returns hold).
This gets enforced on parameters only when strictFunctionTypes is on. Without it, parameters are checked bivariantly and the unsound direction slips through.
// strictFunctionTypes: true
type AnimalFn = (a: Animal) => void;
type DogFn = (d: Dog) => void;
declare const dogFn: DogFn;
// Assigning a Dog-consumer where an Animal-consumer is expected.
// The caller will hand it any Animal; dogFn only knows how to handle Dogs.
const f: AnimalFn = dogFn;
// ^ Error under strictFunctionTypes:
// Type 'DogFn' is not assignable to type 'AnimalFn'.
// Types of parameters 'd' and 'a' are incompatible.
// The safe direction still works:
declare const animalFn: AnimalFn;
const g: DogFn = animalFn; // OK, an Animal-consumer handles any Dog
Turn strictFunctionTypes off (or never turn it on) and the erroring line above compiles silently. That is the unsound assignment the flag exists to catch, and the reason every modern config sets it.
The bivariance hole in methods
Here is the part that catches even experienced people. strictFunctionTypes tightens only parameters written as function-property syntax. Parameters written as method syntax stay bivariant, on purpose, even under strict mode.
Same shape, two spellings, two different rules:
interface Comparer<T> {
// method syntax: checked BIVARIANTLY, always
compareMethod(a: T): void;
// function-property syntax: checked contravariantly under strictFunctionTypes
compareProp: (a: T) => void;
}
declare const dogComparer: Comparer<Dog>;
// Through the method, the unsafe parameter assignment is allowed:
const m: Comparer<Animal>['compareMethod'] = dogComparer.compareMethod; // OK (bivariant)
// Through the property, the same assignment is rejected:
const p: Comparer<Animal>['compareProp'] = dogComparer.compareProp;
// ^ Error: parameter types are incompatible (contravariant)
Why leave a known hole open? Because of arrays and the DOM. Array<T> is declared with method syntax (push(...items: T[]): number, indexOf(item: T): number), and TypeScript wants Dog[] to feel assignable to Animal[] despite push putting T in a contravariant position. Check methods strictly and that everyday assignment errors. Method bivariance is the compromise that keeps Dog[] <: Animal[] alive, and it costs exactly the soundness you’d expect.
The practical takeaway: when you define a callback slot where parameter safety matters, declare it as a property, not a method. onChange: (e: Event) => void gives you the contravariant check; onChange(e: Event): void does not.
Array covariance is a loaded gun
Method bivariance is what makes mutable array covariance type-check, and that covariance is unsound the moment you write to the array.
interface Cat extends Animal { meow(): void }
declare const dogList: Dog[];
const animalList: Animal[] = dogList; // OK, array covariance
declare const someCat: Cat;
animalList.push(someCat); // OK to the compiler, Cat IS an Animal
// But animalList and dogList are the same array. dogList[last] is now a Cat.
dogList[dogList.length - 1].bark(); // compiles, throws at runtime: bark is not a function
Every step type-checks. The break is purely a runtime fact. The standard fix is to stop pretending the array is writable. ReadonlyArray<T> (and readonly T[]) is safely covariant. It removes push, splice, and indexed assignment, the operations that put T in a contravariant position. With no way to write, nothing can exploit the covariance.
declare const roDogs: readonly Dog[];
const roAnimals: readonly Animal[] = roDogs; // OK and sound
roAnimals.push(someCat);
// ^ Error: Property 'push' does not exist on type 'readonly Animal[]'
If you accept arrays you don’t intend to mutate, type the parameter as readonly T[]. You get the covariant assignment for free, and the compiler enforces that you never abuse it.
Saying it out loud: in / out
Since 4.7, TypeScript lets you annotate a type parameter’s variance with out (covariant), in (contravariant), and in out (invariant). The compiler normally infers variance structurally by scanning where the parameter appears. The annotations let you assert it instead.
// out: T appears only in output positions
interface Producer<out T> { get(): T }
// in: T appears only in input positions
interface Consumer<in T> { set(value: T): void }
// in out: T is both produced and consumed, so it must be invariant
interface Box<in out T> { get(): T; set(value: T): void }
These rarely change what compiles. Their real use is performance and intent on large recursive generic types, where structural variance inference runs expensive or circular. An annotation short-circuits the analysis and lets the compiler trust your declaration. The compiler also checks the annotation against actual usage, so a Producer<out T> that secretly consumes T is an error. Treat in/out as documentation the compiler verifies, not a tool you reach for daily.
Variance is also why infer placement behaves the way it does. The position you extract from is the position whose variance decides what unifies. That thread runs through The Many Faces of infer, if you want to follow it.