Ch. 2 · TypeScript

TypeScript Function Variance in Callbacks

Why a Dog handler can't stand in for an Animal handler, how strictFunctionTypes checks callbacks, and the method-syntax loophole it leaves.

~5 min readintermediateupdated Oct 7, 2026

Variance sounds like a type-theory topic, but in TypeScript it comes down to one practical question: when can a function of one type be passed where a function of another type is expected? That question appears every time you pass a callback, register an event handler or type a plugin API. Interviewers like it because the intuitive answer is backwards for parameters, and because TypeScript has a deliberate gap that most people don’t know about.

Three classes and a rule of thumb

The examples use a tiny hierarchy. Dog and Cat both extend Animal, and each adds one method:

class Animal { name = "animal"; }
class Dog extends Animal { bark() { return "woof"; } }
class Cat extends Animal { meow() { return "meow"; } }
TypeScript

The rule of thumb for any function slot: whoever calls the function decides what goes in, and whoever receives the result decides what must come out. Keep asking “who’s calling it, and with what?” and the variance rules follow.

Return types go the way you’d expect

let makeAnimal: () => Animal = () => new Dog();   // ok
let makeDog: () => Dog = () => new Animal();      // error
TypeScript
var1.ts(7,32): error TS2741: Property 'bark' is missing in type 'Animal' but required in type 'Dog'.
Text

Code that calls makeAnimal() expects an Animal. Getting a Dog is fine because a dog is an animal. Code that calls makeDog() expects to be able to call .bark() on the result, and a plain Animal can’t do that. Returns follow the subtype direction, which is called covariance: if Dog is assignable to Animal, then () => Dog is assignable to () => Animal.

Parameters go the other way

Now the part that feels backwards:

let handleAnimal: (a: Animal) => void = (a) => console.log(a.name);
let handleDog: (d: Dog) => void = (d) => console.log(d.bark());

handleDog = handleAnimal;   // ok
handleAnimal = handleDog;   // error
TypeScript
var1.ts(14,1): error TS2322: Type '(d: Dog) => void' is not assignable to type '(a: Animal) => void'.
  Types of parameters 'd' and 'a' are incompatible.
    Property 'bark' is missing in type 'Animal' but required in type 'Dog'.
Text

Ask who calls it. Anyone holding handleAnimal is allowed to call it with any animal, including a Cat. If you’d stored the dog handler there, that call would reach d.bark() on a cat and crash. The safe direction is the reverse: a function that can handle any animal can certainly handle a dog, so it may be stored in a dog-handler slot. Parameter types flip the subtype direction, which is called contravariance.

The interview phrasing to aim for is: a callback has to accept at least everything the caller might pass. A narrower parameter type is a promise you can’t keep.

TypeScript only checks this strictly when strictFunctionTypes is on, and it’s part of strict. Before TypeScript 2.6 introduced the flag, parameters were compared bivariantly, meaning either direction was accepted. Turn the flag off today and the line 14 error disappears, which is a useful demonstration that the check is a compiler setting and not a fact about JavaScript.

Fewer parameters is always fine

A related rule surprises people the other way:

const names = ["a", "b"].map((s) => s.toUpperCase());
TypeScript

Array.prototype.map calls its callback with three arguments: the element, the index and the array. Your callback declares one. That’s allowed because JavaScript silently ignores extra arguments, and a function that ignores some of its input can’t be harmed by receiving it. The reverse, a callback that requires more parameters than the caller provides, is an error. So for parameter count, a callback may take fewer than the slot offers, never more.

The method syntax loophole

Here’s the detail that separates a good answer from a great one. TypeScript applies the strict check to function-typed properties but not to methods:

interface ListenerMethod {
  onEvent(a: Animal): void;
}
interface ListenerProp {
  onEvent: (a: Animal) => void;
}

const dogOnly = { onEvent(d: Dog) { console.log(d.bark()); } };
const m: ListenerMethod = dogOnly; // compiles: methods are bivariant
const p: ListenerProp = dogOnly;   // error
TypeScript
var1.ts(28,7): error TS2322: Type '{ onEvent(d: Dog): void; }' is not assignable to type 'ListenerProp'.
  Types of property 'onEvent' are incompatible.
    Type '(d: Dog) => void' is not assignable to type '(a: Animal) => void'.
      Types of parameters 'd' and 'a' are incompatible.
        Property 'bark' is missing in type 'Animal' but required in type 'Dog'.
Text

The two interfaces look equivalent, and they describe the same objects at runtime. The difference is purely in how they’re checked. Method parameters stay bivariant even under strict. The TypeScript team kept this on purpose: built-in types such as Array<T> declare push(...items: T[]) as a method, and if methods were checked contravariantly, Dog[] would no longer be assignable to Animal[], which a huge amount of existing code relies on.

The practical upshot for API design: if you’re declaring callbacks that users implement, such as event listeners, plugin hooks or strategy objects, write them as properties with arrow types (onEvent: (a: Animal) => void). You get the stricter check for free.

Arrays and the unsoundness you’ve accepted

That same method bivariance is why this compiles:

const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs;      // compiles
animals.push(new Cat());
dogs.forEach((d) => d.bark());
TypeScript

Running the compiled JavaScript prints TypeError: d.bark is not a function. Both variables point at the same array, and the cat went in through the wider alias. A mutable container is used both for input (push) and output (reading elements), so a correct checker would treat it as invariant. TypeScript chooses convenience instead.

The fix is to widen only to a read-only view: const readonlyAnimals: readonly Animal[] = dogs compiles, and readonly Animal[] has no push, so nothing can be inserted. When a function only reads from an array parameter, typing it readonly T[] both documents that and lets callers pass narrower arrays safely.

Since TypeScript 4.7 you can also annotate a type parameter’s variance with in and out, as in type Handler<in T> = (value: T) => void. The compiler checks that the annotation matches how T is actually used, and it can speed up checking for big generic types. You’ll rarely need it in application code, but it’s good to recognise.

Interview practice

An analytics library declares interface Plugin { track(event: BaseEvent): void }. A teammate implements a plugin whose track takes a CheckoutEvent, a subtype with an orderId field, and reads event.orderId.toUpperCase(). It compiles. In production it crashes on page-view events. Why did TypeScript allow it, and how would you change the library’s type?

Answer

The library will call track with every kind of BaseEvent, so a plugin that only handles CheckoutEvent has a narrower parameter than the caller will use. Under strictFunctionTypes that would be rejected, but track is declared with method syntax, and methods are checked bivariantly. TypeScript accepted the narrower parameter, and the page-view event reached event.orderId.toUpperCase() with orderId undefined.

I’d change the interface to property syntax: interface Plugin { track: (event: BaseEvent) => void }. The teammate’s plugin then fails to compile with “Types of parameters ‘event’ and ‘event’ are incompatible”, and they’re pushed to accept BaseEvent and narrow inside, for example with a discriminant like if (event.type === "checkout"). If the library genuinely wants per-event plugins, a better design is a generic registration, on<K extends EventName>(name: K, handler: (event: EventMap[K]) => void), so the handler’s parameter type is tied to the event it’s registered for.

Further reading

More in TypeScript

read ✓TypeScript · mid

TypeScript Enums vs Union Types

What TypeScript enums compile to, why string enums reject plain strings from JSON, and when an as const object beats both options.

~6 min readread →
esc