ML
TypeScript

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.

June 19, 202610 min readTypeScriptTypes

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 to F<Animal> — the relationship points the same way. Return types and readonly containers work like this.
  • Contravariant if F<Animal> is assignable to F<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.
  • strictFunctionTypes only 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) => void to 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[] as Animal[] and pushing a Cat.
  • For your own generics, declare in/out variance. It documents the contract and makes a violated assumption a compile error instead of a runtime surprise.
SharePostLinkedIn

Reader Discussion

1 replies// weighed in

TopNewestAuthor
Add to the thread
Disagree, agree harder, or share your own experience…
Email instead →markdown okbe kind
  1. Léa Dubois· SREAsks

    any chance you'd publish these as a PDF collection? would love to print and read offline on flights. screen-fatigue is real.

    Jun 25, 2026·6 days later

Worked on something similar? Email ducminhldm@gmail.com — I read every one. The good ones become future posts.

Comments seeded · live discussion via email