Skip to main content

TS Ep 52: Variance (Covariance vs Contravariance)

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 52: This Article
If Dog extends Animal, does Array<Dog> extend Array<Animal>? Does (d: Dog) => void extend (a: Animal) => void? To answer these questions, we must dive into Variance.

1. What is Variance?
#

In type theory, Variance describes how complex types (like Arrays, Promises, or Functions) relate to each other based on the relationships of their component parts.

If Dog is a subclass of Animal, how does a function that deals with Dogs relate to a function that deals with Animals?


2. Covariance (Return Types and Objects)
#

Covariance means the assignability relationship goes in the same direction as the underlying types.

If Dog extends Animal, then:

  • Promise<Dog> extends Promise<Animal>
  • Array<Dog> extends Array<Animal>
  • A function returning Dog is assignable to a function returning Animal.

Let’s look at return types:

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

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

// 🟢 SAFE! (Covariance)
// The caller expects an Animal (it only needs the 'name' property).
// getDog returns a Dog (it provides 'name', and also 'bark'). 
// The caller's structural needs are fully met!
getAnimal = getDog;

Because Dog is a superset of Animal structurally, providing a Dog when an Animal is requested is always safe.


3. Contravariance (Function Parameters)
#

Contravariance means the assignability relationship goes in the opposite direction.

If Dog extends Animal, then:

  • A function accepting Animal IS assignable to a function accepting Dog.
  • A function accepting Dog IS NOT assignable to a function accepting Animal.

This usually twists developers’ brains. Why is it backward? Let’s trace it:

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

// 🔴 DANGEROUS! (TypeScript with strictFunctionTypes blocks this!)
// If we allowed this assignment, 'feedAnimal' would secretly be 'feedDog'.
// Later, we might call feedAnimal(new Cat()). 
// The secret 'feedDog' function would receive a Cat and try to call cat.bark() -> CRASH!
// feedAnimal = feedDog; 

// 🟢 SAFE! (Contravariance)
// The caller has a Dog. The 'feedAnimal' function only needs an Animal (needs a name).
// The Dog we pass in definitely has a name, so the function executes safely!
feedDog = feedAnimal;

The Golden Rule of Functions
#

  • Return types are Covariant: You can return something more specific than requested.
  • Parameters are Contravariant: You must accept something less specific (or equally specific) than requested.

4. Bivariance (The TypeScript Quirks)
#

Historically, before TypeScript 2.6, all function parameters were Bivariant (meaning they were treated as both Covariant and Contravariant). This was wildly unsafe and caused many runtime bugs.

In modern TypeScript, you MUST enable "strictFunctionTypes": true in your tsconfig.json to enforce strict Contravariance for function arguments.

However, there is a major loophole: Method shorthand syntax in interfaces remains bivariant by design!

interface Events {
  // 🔴 Bivariant (Unsafe)
  // The compiler will NOT check contravariance here!
  // This syntax exists to make React event handling easier to write.
  onClick(e: Event): void;

  // 🟢 Strict Contravariant (Safe)
  // Using property syntax enforces strict mode.
  onHover: (e: Event) => void;
}

If you are building an ultra-strict internal library, always define function properties using the property: (args) => type syntax rather than the method(args): type syntax!


Summary & Next Steps
#

In this episode:

  • We defined Variance: how complex types relate based on their components.
  • We explored Covariance: why it’s safe to return a Dog when an Animal is expected.
  • We traced Contravariance: why it’s dangerous to accept a Dog parameter when an Animal is expected.
  • We uncovered the Bivariance loophole in interface method syntax.

In Episode 53: Nominal Typing (Branding and Flavoring), we will learn how to break TypeScript’s structural typing rules to create unique, un-assignable primitives!

typescript - This article is part of a series.
Part 52: This Article