Right, let’s talk about variance. It sounds like a terrifying term from category theory, but I promise you, it’s just a precise way to describe how type relationships “flow” when you start nesting types inside other types, like generics. It answers a simple question: If Dog is a subtype of Animal, is Array<Dog> a subtype of Array<Animal>? Your gut might say “yes,” and in TypeScript, for arrays, you’d be right. But that’s not a universal truth, and getting it wrong is how you end up with runtime errors that the type system was supposed to prevent. Let’s break down why.

The Core Concept: Assignment Compatibility

Variance is all about assignment compatibility. It defines what can be assigned to what. We have a simple type hierarchy to work with:

class Animal {
  name: string = '';
}
class Dog extends Animal {
  breed: string = '';
}
class Cat extends Animal {
  lives: number = 9;
}

// This is valid because Dog is a subtype of Animal.
let myAnimal: Animal = new Dog();

Now, let’s put these types inside a container, like a generic Box<T>.

Covariance: The “Yes, Of Course” Flow

Covariance is the one that feels natural. If a type constructor F<T> is covariant in T, then the relationship flows in the same direction: Dog extends Animal implies F<Dog> extends F<Animal>.

TypeScript’s arrays are covariant. So are Promises. This makes sense for “read-only” or “producer” scenarios.

interface Box<T> {
  value: T;
}

let dogBox: Box<Dog> = { value: new Dog() };
let animalBox: Box<Animal>;

// This is valid! Because Box is covariant.
// We can read from `dogBox.value` and safely treat it as an Animal.
animalBox = dogBox;
console.log(animalBox.value.name); // Works perfectly.

But here’s the catch, the absurd part that makes covariance dangerous if you’re not careful. What if we try to write to the covariant box?

// We just assigned dogBox to animalBox, so they reference the same object.
animalBox.value = new Cat(); // This is allowed because animalBox.value expects an Animal, and a Cat is an Animal.

// But wait... dogBox.value is now a Cat!?
console.log(dogBox.value.breed); // 💥 Runtime Error! Property 'breed' does not exist on type 'Cat'.

Boom. The type system allowed this because from its perspective, we put an Animal into an Animal box. But we broke the original dogBox’s contract. This is why covariance is safe for pure producers (like a function that returns T) but dangerous for mutable containers. TypeScript knows this is unsound but allows it for practical reasons, as it’s the JavaScript way. It’s a trade-off.

Contravariance: The “Backwards” Flow

Contravariance is where most people’s brains start to itch. If a type constructor F<T> is contravariant in T, the relationship flows backwards: Dog extends Animal implies F<Animal> extends F<Dog>.

This primarily shows up in function arguments, which are contravariant. Let’s look at a function type.

interface AnimalFeeder {
  feed: (animal: Animal) => void;
}

interface DogFeeder {
  feed: (dog: Dog) => void;
}

let feedMyAnimal: AnimalFeeder = {
  feed: (animal: Animal) => { console.log(`Fed ${animal.name}`) }
};

let feedMyDog: DogFeeder = {
  feed: (dog: Dog) => { console.log(`Fed the ${dog.breed}`) }
};

// This assignment is ILLEGAL and will cause a type error.
// feedMyDog = feedMyAnimal; // ❌ Error: Type 'Animal' is not assignable to type 'Dog'.

// This assignment is LEGAL and safe.
feedMyAnimal = feedMyDog; // ✅ This works!

Why is the second one safe? Think about it. A DogFeeder can only handle Dogs. But an AnimalFeeder claims it can handle any Animal. If I assign a DogFeeder to an AnimalFeeder variable, what happens?

// We've assigned the more specific DogFeeder to the more general AnimalFeeder variable.
const myGeneralFeeder: AnimalFeeder = feedMyDog;

// Now we try to feed a Cat with a function that can only handle Dogs.
myGeneralFeeder.feed(new Cat()); // 💥 Runtime Error! The function tries to access `.breed` on a Cat.

Wait, didn’t you just say that assignment was safe? It is type-safe. The error happens at runtime because we did something we said we wouldn’t: we passed a Cat to a variable we typed as AnimalFeeder. The type system correctly prevented the truly dangerous assignment (feedMyDog = feedMyAnimal) which would have allowed calling feedMyDog.feed(new Cat()) directly. Contravariance is the type system’s way of being conservative and correct. A function that handles a wider type (Animal) can always stand in for a function that handles a narrower one (Dog).

Invariance: The “Absolutely Not” Stance

Invariance is the strictest rule. If a type constructor F<T> is invariant in T, then F<Dog> has no relationship to F<Animal>, even if Dog extends Animal. You cannot assign them in either direction.

This is what you get when a type is both a producer and a consumer (a.k.a. has both read and write capabilities). Our mutable Box<T> from earlier should really be invariant for total safety.

TypeScript doesn’t have explicit invariance keywords, but you can observe it with certain structures or by using strictFunctionTypes.

interface MutableBox<T> {
  get: () => T;
  set: (value: T) => void;
}

let dogMutableBox: MutableBox<Dog> = {
  get: () => new Dog(),
  set: (value: Dog) => { /* ... */ }
};

let animalMutableBox: MutableBox<Animal> = {
  get: () => new Animal(),
  set: (value: Animal) => { /* ... */ }
};

// Both of these assignments are ILLEGAL under strict type checking.
// dogMutableBox = animalMutableBox; // ❌ Error: 'get' and 'set' are incompatible.
// animalMutableBox = dogMutableBox; // ❌ Error: 'get' and 'set' are incompatible.

The type system rightly blocks both assignments. The first is unsafe for writing (we could set a Cat into a Dog box). The second is unsafe for reading (we could get a Dog from an Animal box and assume it has a .breed property when it might not). Invariance is the only sound choice here.

Bivariance: TypeScript’s “Legacy Mode”

Earlier, I mentioned function arguments are contravariant. This is true under the strictFunctionTypes compiler flag, which you should always have on. If you turn it off, TypeScript falls back to bivariance, a truly absurd rule where both assignments are allowed. It’s a convenience hack for older code but is profoundly unsound. Consider it a reminder of why you always use strict mode.

So What Should You Do?

  1. Use strictFunctionTypes. Always. There’s no good reason not to.
  2. Think about intent. When designing a generic type, ask: Is this a producer (() => T), a consumer ((input: T) => void), or both? This tells you the variance you need.
  3. Use readonly. To safely express covariance, use readonly. A ReadonlyArray<Dog> can be safely treated as a ReadonlyArray<Animal> because you can’t push a Cat into it and break the world.
  4. Accept that mutability is a minefield. TypeScript’s covariant arrays are a pragmatic choice for working with JavaScript, but never forget the potential unsoundness. Structure your code to avoid mutating values through covariantly typed references.

Understanding this isn’t just academic. It’s the difference between knowing the type system has your back and realizing it’s been nodding along while you hang yourself with a rope it handed you.