Variance: Why TypeScript Lets You Pass the Wrong Function
Covariance, contravariance, and the method-vs-function loophole that lets an unsound callback slip past the type checker even with strict on.
Variance is the rule that decides, given Dog extends Animal, whether a Box<Dog> is also a Box<Animal>. Most people never name it, then spend an afternoon confused about why TypeScript accepted a callback that clearly takes the wrong argument. The answer is almost always variance, and TypeScript has one deliberate hole in it.
1. The two directions
Start with the subtype relationship Dog extends Animal (a Dog is usable wherever an Animal is). A type constructor F<T> is:
- Covariant if
F<Dog>is assignable toF<Animal>— the relationship points the same way. Return types and readonly containers work like this. - Contravariant if
F<Animal>is assignable toF<Dog>— the relationship flips. Function parameter positions work like this.
The contravariant flip surprises people, so make it concrete. A function that can handle any Animal can stand in for one that was only promised a Dog — it asks for less, so it is safe to pass more:
type Animal = { name: string };
type Dog = Animal & { bark(): void };
type DogHandler = (d: Dog) => void;
const handleAnimal = (a: Animal) => console.log(a.name);
const handleDog = (d: Dog) => d.bark();
let h: DogHandler;
h = handleAnimal; // OK — needs less than a Dog, safe
h = handleDog; // OK — exactly a Dog
// the reverse is the danger:
let g: (a: Animal) => void;
// g = handleDog; // should fail — handleDog will call .bark() on a cat
That last line is the whole point of contravariance: a handler that demands a Dog must not be usable where any Animal might be passed, because it would call .bark() on something that has no bark.
2. The hole: methods are bivariant
Here is the gotcha that costs people an afternoon. TypeScript checks function parameters contravariantly only when the function is written in function-property syntax and strictFunctionTypes is on. When the same signature is written in method syntax, parameters are checked bivariantly — both directions pass — regardless of strict mode.
interface FnStyle { handle: (d: Dog) => void } // checked contravariantly
interface MethodStyle { handle(d: Dog): void } // checked BIVARIANTLY
const wantsAnimal = { handle: (a: Animal) => a.name };
let a: FnStyle = wantsAnimal; // OK (Animal handler is fine for a Dog slot)
const wantsDog = { handle: (d: Dog) => d.bark() };
// let b: FnStyle = wantsDog; // ERROR with strictFunctionTypes — good
let c: MethodStyle = wantsDog; // NO ERROR — the bivariance hole
So the exact same unsound assignment is rejected with the arrow form and silently accepted with the method form. This is not a bug; it is a compatibility decision (more on why below).
3. Why the hole exists on purpose
Two reasons. First, the built-in library leans on it: Array<T> is treated as covariant so that Dog[] is assignable to Animal[], which is what everyone intuitively wants for reads — but Array.prototype.push(item: T) has T in a parameter position, which strict contravariance would reject. Methods being bivariant is what lets Dog[] stay assignable to Animal[] without the compiler choking on push. The unsoundness (you could push a Cat into the aliased Animal[]) is the price.
Second, event-handler ergonomics. addEventListener("click", (e: MouseEvent) => ...) would be a contravariance error against a (e: Event) => void slot under strict rules, and nobody wanted to write casts on every listener. strictFunctionTypes was scoped to function-syntax properties precisely to tighten the common case while leaving methods (and therefore the DOM and array APIs) working.
4. Forcing the variance you actually want
If you want the strict check, write the field as an arrow property, not a method. If you are authoring a generic and want the compiler to enforce a direction, TypeScript 4.7+ lets you annotate it explicitly with in (contravariant) and out (covariant) markers, which also documents intent and speeds up checking:
interface Producer<out T> { get(): T } // covariant
interface Consumer<in T> { set(value: T): void } // contravariant
interface Channel<in out T> { get(): T; set(v: T): void }
If you write out T but then use T in a parameter position, the compiler now tells you the annotation is violated — turning a silent variance assumption into a checked one.
Rules of thumb
- Return positions are covariant, parameter positions are contravariant. Memorise the flip: a function is more reusable the less it demands of its arguments.
strictFunctionTypesonly tightens parameters on function-property syntax. Method syntax (and constructors) stay bivariant — that is intentional, not a leak you can flip off.- If a clearly-wrong callback type-checks under strict mode, the slot is almost certainly declared as a method. Rewrite it as
handle: (x) => voidto get the real check. - Array/collection covariance is convenient but unsound on writes — don't rely on the type system to stop you aliasing a
Dog[]asAnimal[]and pushing a Cat. - For your own generics, declare
in/outvariance. It documents the contract and makes a violated assumption a compile error instead of a runtime surprise.