skip to content

Why did TypeScript's designers leave method parameters bivariant instead of checking them contravariantly like other function types, and what unsoundness does that leave in everyday code?

level: seniorimportance: should knowfreq 42%

answer

  1. usability beat soundness here
  2. the element type sits in parameter positions
  3. push is why arrays stay assignable
  4. two aliases, one underlying object
  5. readonly removes the destructive move

basics

~20 s

Contravariant method parameters would make generic types unusable: Array puts its element type in parameter positions such as push and indexOf, so an array of a subtype would stop being assignable to an array of its supertype. TypeScript traded soundness for usability.

solid answer

~40 s

It is a deliberate, documented pragmatic choice. Generic container types put their type parameter in method *parameter* positions — `Array<T>` declares `push(...items: T[])` and `indexOf(searchElement: T)` — so under a contravariant rule `Dog[]` would no longer be assignable to `Animal[]`, and a huge amount of ordinary, mostly-correct code would stop compiling. Exempting methods keeps those types relating covariantly. The price is genuine unsoundness in two everyday shapes: arrays widen freely, so `const animals: Animal[] = dogs; animals.push(cat)` type-checks and corrupts `dogs`; and any implementation of a method-declared callback may narrow its parameter, so it can be handed a value it never expected. Nothing catches either at runtime, because types are erased. Mitigate by taking `readonly T[]` parameters where you do not mutate, and by declaring consumer-supplied callbacks as property function types.

code

typescript · 18 lines
typescript
class Animal { name = "a"; }
class Dog extends Animal { fetch() {} }
class Cat extends Animal { meow() {} }

const dogs: Dog[] = [new Dog()];

// Allowed: Array<T> keeps relating covariantly because push/indexOf are methods.
const animals: Animal[] = dogs;
animals.push(new Cat()); // type-checks, and mutates dogs

// dogs[1] is a Cat; fetch is undefined at runtime.
console.log(dogs[1].fetch);

// Hardening: a readonly view has no push at all.
function countNames(items: readonly Animal[]): number {
  return items.length;
}
countNames(dogs);

go deeper

for a junior

Know that assigning an array of a subtype to an array of its supertype is allowed, and that writing through the result can put the wrong kind of value into the original array.

for a middle

Explain that the element type appears in method parameter positions such as push and indexOf, and that exempting methods from the contravariant rule is what keeps those assignments legal.

for a senior

Recognise the symptom in production — a property that is undefined on a value the types vouch for — trace it to a widening assignment or a narrowed handler parameter, and reach for readonly parameters and property-form callbacks.

for a principal

Be able to argue the trade itself: which unsoundness a language should keep to stay usable, and how you decide which surfaces in your own codebase are worth hardening against it.

## The rule and the exemption When TypeScript relates two function types, the sound rule for parameters is contravariance: a replacement must accept everything callers were promised they could pass. `strictFunctionTypes` implements that rule — but it deliberately does **not** apply it to members declared with method syntax (or to constructor signatures). Those keep the older **bivariant** comparison, where the relation passes if the parameter types are assignable in either direction. This is not an accident or a bug backlog item. It is documented as an intentional trade. ## Why the exemption had to exist The reason is generic container types. Look at what `Array<T>` actually declares: ```ts interface Array<T> { push(...items: T[]): number; indexOf(searchElement: T, fromIndex?: number): number; includes(searchElement: T, fromIndex?: number): boolean; concat(...items: ConcatArray<T>[]): T[]; } ``` `T` appears in parameter positions all over it. Measure variance structurally and honestly, and `Array<T>` is **invariant** in `T`: neither `Dog[] → Animal[]` nor the reverse is safe. That is the mathematically correct answer, and it is unusable — passing a `Dog[]` to a function that takes `Animal[]` is something almost every codebase does dozens of times, and almost always correctly, because the callee only reads. By exempting methods from the strict rule, TypeScript makes `Array<T>` (and `Map`, `Set`, `Promise`, and every user-written interface built the same way) relate essentially covariantly, so those assignments keep working. The designers accepted a known hole in exchange for not breaking the language's ergonomics. ## The hole, concretely The canonical demonstration is array aliasing: ```ts class Animal { name = "a"; } class Dog extends Animal { fetch() {} } class Cat extends Animal { meow() {} } const dogs: Dog[] = [new Dog()]; const animals: Animal[] = dogs; // allowed animals.push(new Cat()); // type-checks dogs[1].fetch; // undefined at runtime ``` `animals` and `dogs` are the *same object*. The type system permitted the widening and then permitted a write through the widened view, so a `Cat` is now sitting in something typed `Dog[]`. Nothing at runtime notices, because the types are erased; the failure surfaces later, wherever someone calls `.fetch()`. The second shape is callbacks. Any interface that declares a handler with method syntax lets an implementation narrow the parameter: ```ts interface Sink { accept(e: Event): void } const s: Sink = { accept(e: MouseEvent) { console.log(e.clientX); } }; // OK s.accept(new KeyboardEvent("keydown")); // compiles; clientX is undefined ``` Every caller is entitled by the type to pass any `Event`, and the implementation quietly assumed something stronger. ## Diagnosing it in the wild The symptom is a property that is `undefined` (or a method that is not a function) on a value whose type says it should be there, with no assertion or `any` anywhere near the failure. When that happens, look upstream for a widening assignment of a mutable collection, or for an implementation whose parameter type is narrower than the interface it satisfies. Neither shows up as a compile error, so grep-by-symptom is the only route. ## Living with it - **Take `readonly T[]` for parameters you do not mutate.** `readonly Animal[]` has no `push`, so the widening stops being a write hazard. It is still technically unsound in the same way, but the destructive move is gone from the API. - **Declare consumer-supplied callbacks as property function types.** The contravariant check then applies, and a narrower handler is a compile error at the point where it is written. - **Narrow inside the body instead of in the signature.** `if (e instanceof MouseEvent)` is a check that actually exists after erasure; a narrower parameter type is only a promise. - **Do not reach for `strictFunctionTypes: false`.** It removes the sound check from the forms that still have it and buys nothing back. - **Know when to shrug.** Most covariant array passing is read-only and fine. The judgment is about which surfaces are worth hardening, not about eliminating the hole — it cannot be eliminated without breaking the language.

  • If the exemption is unsound, why not make Array invariant and force people to annotate?
    Because the annotation burden would land on correct code. The overwhelming majority of `Dog[]` to `Animal[]` passes are read-only and safe; invariance would reject all of them to catch a rare mutation bug. TypeScript's stated design priority is catching real errors without making ordinary JavaScript patterns unexpressible.
  • Does `readonly T[]` make the assignment sound?
    It removes the mutation hazard, not the variance. `readonly Dog[]` is still assignable to `readonly Animal[]`, but the target has no `push` or `splice`, so nothing can write a `Cat` through the widened view. That is the practical fix even though the relation itself remains technically unsound.
  • Which other everyday construct relies on the same bivariant exemption?
    Constructor signatures are exempted the same way, and so is every user-written interface whose members are declared with method shorthand — including `Map`, `Set` and `Promise`-shaped types in the standard library. Any of them can be widened and then written through in the same manner.

saying these in an interview costs you the question

  • Calls method bivariance a compiler bug that will be fixed
  • Claims strict mode already closes the array hole
  • Thinks runtime checks stop the wrong element from being pushed
  • Says readonly arrays make the assignment sound rather than non-destructive
  • Proposes disabling strictFunctionTypes as the response

context