Mert Özen Further With Every Line
Frontend TypeScript Advanced — #06

Variance and Assignability in TypeScript: Covariance, Contravariance and strictFunctionTypes

Mert Özen Nov 3, 2026 8 min 4 views
Variance and Assignability in TypeScript: Covariance, Contravariance and strictFunctionTypes

We look at the direction of assignability, covariance in return types, contravariance in parameters, strictFunctionTypes, method bivariance, the array soundness hole and in/out variance annotations.

Version note: The code in this post targets TypeScript 7.0 and Node 26.

Predict First

We wrote two interfaces and a handler function for each. We'll use these two interfaces in every example in this post:

interface Animal {
  name: string;
}
interface Dog extends Animal {
  bark(): string;
}

let handleAnimal = (animal: Animal) => console.log(animal.name);
let handleDog = (dog: Dog) => console.log(dog.bark());

handleDog = handleAnimal;
handleAnimal = handleDog;

One of the last two lines errors. Which one? Your intuition probably says "every Dog is an Animal, so whatever deals with Dog can stand in for whatever deals with Animal." That intuition is right for values; for functions it can be the exact opposite. The answer comes after we talk about the direction of assignability.

Assignability Has a Direction

When we discussed structural typing in the Basics series, we set up this rule: a value that carries everything a type expects is assignable to that type. Dog carries the name that Animal asks for, so Dog is assignable to Animal. In this relationship Dog is the subtype and Animal the supertype. The arrow always points from the subtype to the supertype: Dog → Animal.

Variance asks this question: how does that arrow carry over to composite types that contain Dog and Animal? Between Dog[] and Animal[], () => Dog and () => Animal, (dog: Dog) => void and (animal: Animal) => void, which way does the arrow point? There are three possible answers: the same way (covariance), the opposite way (contravariance), or neither way (invariance).

Covariance: The Same Direction

Let's start with return types:

let getDog = (): Dog => ({ name: "Rex", bark: () => "Woof" });
let getAnimal = (): Animal => ({ name: "Generic" });

getAnimal = getDog;

// Error: Type '() => Animal' is not assignable to type '() => Dog'.
getDog = getAnimal;

Code that calls getAnimal expects an Animal. Return a Dog and that expectation is more than met; no problem. In the other direction, code that calls getDog expects a value with bark, and getAnimal doesn't guarantee that. The arrow points the same way: since Dog → Animal, also () => Dog → () => Animal. Return types, readonly properties and readonly arrays are covariant; in other words, if a value only comes out, if it's only read, handing over the subtype is safe.

Contravariance: The Opposite Direction

With parameters the arrow flips. Let's think through the lines from the prediction one at a time. handleDog = handleAnimal compiles: code that calls handleDog will always give it a Dog, and handleAnimal only needs a name. Taking a dog to a vet who handles every animal is safe. handleAnimal = handleDog, however, errors:

error TS2322: Type '(dog: Dog) => void' is not assignable to type '(animal: Animal) => void'.
  Types of parameters 'dog' and 'animal' are incompatible.
    Property 'bark' is missing in type 'Animal' but required in type 'Dog'.

Code that calls handleAnimal may give it any animal, say a cat. If handleDog has been put in its place, we end up asking a cat to bark(). You can't send every animal to a vet who only treats dogs. The arrow turns around: while Dog → Animal, it's (animal: Animal) => void → (dog: Dog) => void. Parameters are contravariant.

The quick way to remember it is this question: "Who will call this function, and what will they give it?" If a value goes into the function, the arrow flips; if it comes out of the function, it keeps its direction.

strictFunctionTypes

This check wasn't always like this. Until TypeScript 2.6, parameters were bivariant, meaning assignability was accepted in both directions. The strictFunctionTypes option introduced in TypeScript 2.6 made parameters contravariant, and it is part of the strict family today; since strict has been on by default since TS 6.0, this check is on too. It's instructive to see what happens when you turn it off:

let handleAnimal = (animal: Animal) => console.log(animal.name);
let handleDog = (dog: Dog) => console.log(dog.bark());

// Error: Type '(dog: Dog) => void' is not assignable to type '(animal: Animal) => void'.
handleAnimal = handleDog;

// With strictFunctionTypes off, the line above compiles and this call crashes:
// TypeError: dog.bark is not a function
handleAnimal({ name: "Generic" });

Check the same file with npx tsc --noEmit --strictFunctionTypes false and the error disappears, because the compiler allows the assignment in both directions. Run it, and exactly the scenario we just described happens: the handler that thinks it's dealing with a dog receives an animal that isn't one. There is no good reason to turn this option off.

Method Bivariance

strictFunctionTypes has a deliberate exception: signatures written with method syntax stay bivariant even in strict mode. Let's write the same contract in two ways:

interface AnimalHandler {
  handle(animal: Animal): void;
}

const dogOnly: AnimalHandler = {
  handle(dog: Dog) {
    console.log(dog.bark());
  },
};

dogOnly.handle({ name: "Generic" }); // TypeError: dog.bark is not a function

The code compiles without errors in strict mode and crashes at runtime. Because handle(animal: Animal): void is a method signature, the compiler accepted the parameter in both directions. Write it with property syntax and the check comes back:

interface StrictAnimalHandler {
  handle: (animal: Animal) => void;
}

const strictDogOnly: StrictAnimalHandler = {
  // Error: Type '(dog: Dog) => void' is not assignable to type '(animal: Animal) => void'.
  handle: (dog: Dog) => console.log(dog.bark()),
};

The reason for the exception is arrays. Methods of Array<T> such as push(...items: T[]) take T as a parameter. If methods were contravariant too, Dog[] wouldn't be assignable to Animal[], and a large share of everyday code would error. TypeScript chose practicality here. The lesson for you is simple: when you define callbacks and handlers, use property syntax if type safety matters.

Invariance and the Secret of Arrays

If a type parameter is both read and written, the two rules lock each other:

type Box<T> = {
  get: () => T;
  set: (value: T) => void;
};

declare let animalBox: Box<Animal>;
declare let dogBox: Box<Dog>;

// Error: Type 'Box<Dog>' is not assignable to type 'Box<Animal>'.
animalBox = dogBox;

// Error: Type 'Box<Animal>' is not assignable to type 'Box<Dog>'.
dogBox = animalBox;

get wants covariance, set wants contravariance. Together they allow no direction at all: Box is invariant. A mutable array is really a box like this; you both read its elements and write into it. But thanks to the method bivariance we just saw, TypeScript treats arrays as covariant, and that has a cost:

interface Cat extends Animal {
  meow(): string;
}

const dogs: Dog[] = [{ name: "Rex", bark: () => "Woof" }];
const animals: Animal[] = dogs;
const tom: Cat = { name: "Tom", meow: () => "Meow" };
animals.push(tom);

for (const dog of dogs) {
  console.log(dog.bark());
}
// Woof
// TypeError: dog.bark is not a function

animals and dogs are the same array. We put a cat into it through Animal[], and while reading through Dog[] we took it for a dog. The compiler objected to no line. This is a hole TypeScript accepts on purpose. If a function will only read an array, write the parameter as readonly Animal[]: since you can't push to a readonly array, covariance really is safe there.

Variance Annotations: in and out

Normally TypeScript works out a type parameter's variance from the structure on its own. Since TypeScript 4.7 we can write it explicitly: out means covariant, in contravariant, and in out invariant:

interface Producer<out T> {
  produce: () => T;
}
interface Consumer<in T> {
  consume: (value: T) => void;
}
interface Store<in out T> {
  get: () => T;
  set: (value: T) => void;
}

// Error: Type 'WrongProducer<sub-T>' is not assignable to type 'WrongProducer<super-T>' as implied by variance annotation.
interface WrongProducer<out T> {
  consume: (value: T) => void;
}

If an annotation contradicts the variance the compiler finds, you get TS2636. WrongProducer uses T only in a parameter, so its structure is contravariant, yet we called it out. The sub-T and super-T in the message are the subtype and supertype the compiler makes up to run the test.

Annotations can only be written on interfaces, classes and type aliases that define an object or function type; anywhere else you get TS2637 or TS1274. When should you use them? Rarely. They are useful for documenting intent in a library's public types and for shortening the compiler's variance computation in very deep recursive types. Don't use them to silence an error; an annotation has to be consistent with the structure, and at most it can make a type stricter.

Common Pitfalls

The first is relying on array covariance. Passing a Dog[] to a function that takes an Animal[] parameter compiles; if the function writes to the array, you'll meet the cat from earlier at runtime. In functions that only read, a readonly array parameter both documents the intent and closes the hole.

The second is method syntax. onSelect(item: Product): void and onSelect: (item: Product) => void look like they say the same thing, but the first one loosens the parameter check even in strict mode.

The third is the assignability of readonly properties. At the end of the previous post we saw that a DeepReadonly<Settings> value can't be assigned to Settings; the objection came only from the readonly array. A readonly property, however, is silently assignable to a mutable one: you can pass a value of type { readonly name: string } where { name: string } is expected and then change it. readonly is a compile-time promise; it creates no direction in assignability.

A Small Experiment

★☆☆ Predict: given let fromDog: (dog: Dog) => Animal; and let fromAnimal: (animal: Animal) => Dog;, does fromDog = fromAnimal compile, or fromAnimal = fromDog? Think about the parameter and the return type separately.

★★☆ With interface Listener<T> { notify(value: T): void }, assign a Listener<Dog> value to a Listener<Animal> variable. Does it compile? Then write notify with property syntax and try again.

★★★ Write type Getter<out T> = () => T; and see that it compiles. Then change out to in and read the TS2636 message: which assignment does the compiler say fails, and why?

In this post we discussed the directions in which structural typing allows assignment. But what about when we want two types to never be assignable to each other? If UserId and OrderId are both strings, TypeScript can't tell them apart. In the next post we'll get past that limit of structural typing with branded types: brands that separate types with the same shape, unique symbol, and producing brands with validation functions.