DEV Community

nyaomaru
nyaomaru

Posted on

🧠 Understanding Variance in TypeScript & Flow: Covariant, Contravariant, Invariant, Bivariant

Hi everyone!

I’m @nyaomaru, a frontend engineer who quietly moved to the Netherlands. 🇳🇱

If you write TypeScript, you’ve probably bumped into the term “variance” at some point:

  • covariant
  • contravariant
  • invariant
  • bivariant

You may have a vague feeling of “I sorta get it… but not really.”

Personally, I struggled especially with contravariance and bivariance — they’re really counter-intuitive.

And when I tried to deep-read React’s type definitions, I ran into Flow’s variance annotations +T / -T and completely froze:

export type Element<+C> = React$Element<C>;
export type RefSetter<-I> = React$RefSetter<I>;
Enter fullscreen mode Exit fullscreen mode

“What are + and -!?”
“Why is React using this in Flow!?”

That was the entrance to understand variance for me.

In this article, I’ll use both TypeScript and Flow to build a practical, real-world understanding of variance:

  • You want to read React’s type definitions without crying 😿
  • You want to design safe callback types in TypeScript
  • You want to avoid the bivariant foot-guns

Let’s dive in 👇

Note
I won’t do a full Flow tutorial here. I’ll only touch Flow enough to explain how it expresses variance.


🧩 What is type variance?

“Type variance” is about generic types and function types, and:

how the subtype relationship between type parameters propagates to the outer type

For example, suppose Cat is a subtype of Animal (Cat <: Animal):

  • Then is List<Cat> also a subtype of List<Animal>?
  • Can we use Handler<Animal> where a Handler<Cat> is expected?
  • What about something mutable like Box<T>?

Variance is the set of rules that determines these “generic subtype” relationships.

There are four basic flavors:

  • Covariant
    • Subtype relationship goes in the same direction
  • Contravariant
    • Subtype relationship goes in the opposite direction
  • Invariant
    • No subtype relationship either way
  • Bivariant
    • Both directions are allowed (TypeScript foot-gun)

These are not specific to TypeScript — you’ll find them in Java, C#, Kotlin, Flow, and pretty much any typed language that has generics.

Let’s go through them one by one.

⬆️ Covariant: “If child is OK, using it as parent is also OK”

Given Cat <: Animal, covariance is:

Covariant: F<Cat> <: F<Animal>

Roughly: a “container of more specific things” can be used where a “container of more general things” is expected.

This is typically used for read-only types.

class Animal {
  name = 'animal';
}
class Cat extends Animal {
  meow() {}
}

type ReadonlyBox<T> = {
  readonly value: T;
};

const catBox: ReadonlyBox<Cat> = {
  value: new Cat(),
};

// Using "box of Cat" as "box of Animal" is fine
const animalBox: ReadonlyBox<Animal> = catBox;

// Reading as Animal is always safe
// animalBox.value.meow(); // Type error: Animal doesn't have meow()
Enter fullscreen mode Exit fullscreen mode

Here we only read from the box, so it’s safe to treat a ReadonlyBox<Cat> as a ReadonlyBox<Animal>. That’s covariance.

  • Top: CatAnimal (usual subtype relation)
  • Bottom: ReadonlyBox<Cat>ReadonlyBox<Animal> (same direction → covariant)

⬇️ Contravariant: “If parent is OK, you can use it in a child-only slot”

Again with Cat <: Animal, contravariance is:

Contravariant: F<Animal> <: F<Cat>

The idea is:

A function that can handle a wider type can safely be used wherever a narrower type handler is required.

This comes up with function parameter types (i.e., “write-only” positions).

class Animal {
  name = 'animal';
}
class Cat extends Animal {
  meow() {}
}

type Handler<T> = (value: T) => void;

const handleAnimal: Handler<Animal> = (animal: Animal) => {
  console.log(animal.name);
};

const handleCat: Handler<Cat> = (cat: Cat) => {
  cat.meow();
};

// Safe with contravariance:
// A handler that accepts any Animal can be used as a Cat-specific handler
const catHandler: Handler<Cat> = handleAnimal; // OK (in theory)

// The opposite is unsafe: a Cat-only handler
// cannot safely handle all Animals (Dog, Bird, ...)
// const animalHandler: Handler<Animal> = handleCat; // Should be an error
Enter fullscreen mode Exit fullscreen mode

Handler<Animal> can handle any animal (including Cat), so it’s safe to use where a “Cat handler” is expected.

The reverse is not safe → that’s contravariance.

  • Top: CatAnimal
  • Bottom: Handler<Animal>Handler<Cat> (reverse direction → contravariant)

⛔ Invariant: “No subtyping either way”

Even if Cat <: Animal , with invariance:

Invariant: F<Cat> and F<Animal> have no subtype relation

class Animal {
  name = 'animal';
}
class Cat extends Animal {
  meow() {}
}

type Box<T> = {
  value: T;
};

let animalBox: Box<Animal> = { value: new Animal() };
let catBox: Box<Cat> = { value: new Cat() };

// Both of these are theoretically unsafe:
//
// animalBox = catBox;
// catBox = animalBox;
Enter fullscreen mode Exit fullscreen mode

Why?

  • If you treat Box<Cat> as Box<Animal>:
    • You could write a plain Animal into it
    • But someone else might assume it still only contains Cat
  • If you treat Box<Animal> as Box<Cat>:
    • You might pull an Animal from it and assume it’s always a Cat

Since it’s mutable and used for both read and write, the safe option is:

Make it invariant (no subtyping).

In pure type theory we’d reject both assignments.

In practice, TypeScript treats most generics as approximately covariant, so Box<Cat>Box<Animal> may compile — but conceptually, Box<T> should be invariant.

  • Top: CatAnimal
  • Bottom: no arrow between Box<Cat> and Box<Animal> → invariant

🔁 Bivariant: “Both directions are OK (and that’s exactly the problem)” — TypeScript’s hole

Given Cat <: Animal, bivariance is:

Bivariant: F<Cat> and F<Animal> are mutually assignable

So it’s like “covariant + contravariant at the same time”.
From a soundness standpoint, this is pretty much always a bad idea.

But TypeScript allows it in some cases (esp. some callback parameter types) for JavaScript compatibility.

type EventHandler<T> = (event: T) => void;

declare let handleEvent: EventHandler<Event>;
declare let handleMouse: EventHandler<MouseEvent>;

// In sound type theory, only one of these would be allowed (depending on design).
// In TypeScript, *both* can be allowed in certain contexts (bivariant).
handleEvent = handleMouse; // OK in some cases (but can be unsafe)
handleMouse = handleEvent; // Also OK
Enter fullscreen mode Exit fullscreen mode
  • MouseEvent is a subtype of Event
  • If both directions are allowed, you can pass the “wrong” handler
  • TypeScript chooses practicality over strict safety here

  • Top: MouseEventEvent
  • Bottom: Handler<MouseEvent>Handler<Event> → bivariant

🎯 Quick summary of the four

Kind Direction Safety Typical example
Covariant Child → Parent Safe for reads readonly T[], ReadonlyArray<T>
Contravariant Parent → Child Safe for writes (arg: T) => void, handlers
Invariant No subtyping Safe for mutable Box<T>
Bivariant Both directions Unsound / risky Some TS callback parameter positions

⚙️ TypeScript’s reality vs. “pure” type theory

So far we’ve been in the “beautiful, clean theory world”:

  • Read-only → covariant
  • Write-only (function parameters) → contravariant
  • Mutable read + write → invariant
  • Both directions → unsound (bivariant)

But TypeScript does not fully implement this ideal.

Because of:

  • Historical JavaScript APIs
  • Massive existing JavaScript codebases
  • Browser / DOM APIs design

TypeScript makes several pragmatic compromises:

  • T[] arrays are treated as covariant, even though they should be invariant
  • Function parameter positions are often bivariant, not strict contravariant

This mismatch between type theory intuition and TS behavior is where a lot of confusion comes from.

Let’s look at the two big ones.

Arrays: should be invariant, but TS treats them as “basically covariant”

Formally:

T[] should really be invariant
but TypeScript treats it as if it were covariant

Which means some unsafe code compiles.

class Animal {
  name = 'animal';
}
class Cat extends Animal {
  meow() {}
}
class Dog extends Animal {
  bark() {}
}

const cats: Cat[] = [new Cat()];

// TS treats Cat[] as a subtype of Animal[]
const animals: Animal[] = cats;

// Now this is allowed:
animals.push(new Dog());

// But the underlying array is still "cats"
const cat: Cat = cats[1]; // Type says Cat, but it's actually a Dog
cat.meow(); // Possible runtime error
Enter fullscreen mode Exit fullscreen mode

Why does TS allow this?

  • From a type theory perspective, T[] is a mutable container → should be invariant
  • But JavaScript’s arrays are extremely flexible and historically treated loosely
  • Making T[] strictly invariant would break a lot of existing JavaScript code

So TypeScript chose to keep T[] almost covariant.

The recommended alternative is read-only arrays:

const cats: readonly Cat[] = [new Cat()];
const animals: readonly Animal[] = cats; // Safe
Enter fullscreen mode Exit fullscreen mode

Because we only read from a readonly array, it can be safely covariant.

In practice: most generics in TS behave “mostly covariant”.
The real danger is specifically “mutable but treated as covariant”, like T[].

Function parameters: should be contravariant, but often behave bivariantly

The second big compromise: function parameter variance.

In theory:

Function parameter positions should be contravariant.

In practice, TypeScript often treats them as bivariant, especially in:

  • Methods in object types
  • Some callback positions
  • When strictFunctionTypes is disabled

Let’s see why that’s a problem.

Why should parameters be contravariant?

type Handler<T> = (value: T) => void;
Enter fullscreen mode Exit fullscreen mode

Here, T is used in parameter position:

  • It’s on the “receiving data” side
  • Which means it should be contravariant

If Cat <: Animal, then (in theory):

Handler<Animal> <: Handler<Cat>
Enter fullscreen mode Exit fullscreen mode

We’ve already seen this pattern.

But TS sometimes allows both directions (bivariant)

To maintain compatibility with existing JavaScript, TS allows both directions in many callback cases:

type Handler<T> = (value: T) => void;

declare let handleEvent: Handler<Event>;
declare let handleMouse: Handler<MouseEvent>;

// In some contexts, TS allows:
handleEvent = handleMouse; // OK
handleMouse = handleEvent; // OK
Enter fullscreen mode Exit fullscreen mode

With a purely sound type system, one of these would be rejected.
TS chooses convenience over strict safety.

Consider this more dangerous version:

type Handler<T> = (value: T) => void;

const handleEvent: Handler<Event> = (event) => {
  console.log(event.type);
};

const handleMouse: Handler<MouseEvent> = (event) => {
  // MouseEvent-specific API
  console.log(event.clientX);
};

// Assign MouseEvent handler to a generic Event handler
const eventHandler: Handler<Event> = handleMouse;

// This is allowed by the type system:
eventHandler(new Event('click')); // Runtime error: no clientX
Enter fullscreen mode Exit fullscreen mode

This should be a type error, but bivariant behavior lets it through.

Why did the TS team do this?

Because real-world JavaScript:

  • Doesn’t assume strict contravariance
  • Has tons of “loosely typed” callback APIs (DOM, Node.js, libraries, …)
  • Would break massively if TS suddenly enforced full contravariance

So the TS team made a pragmatic choice:

Prioritize developer experience and compatibility
over 100% soundness.

What about strictFunctionTypes?

There is a partial escape hatch:

{
  "compilerOptions": {
    "strict": true,
    "strictFunctionTypes": true
  }
}
Enter fullscreen mode Exit fullscreen mode

With strictFunctionTypes: true:

  • Standalone function types are treated more contravariantly
  • Methods in object types are still treated more loosely (bivariantly) for compatibility

So even in strict mode, you don’t get “perfect” contravariance — but you get closer.

🎯 Takeaway: TypeScript is not a “pure variance lab”

To summarize:

  • Arrays T[] should be invariant → TS treats them as almost covariant
  • Function parameters should be contravariant → TS often treats them as bivariant
  • strictFunctionTypes helps, but doesn’t give you fully sound variance

So TypeScript is:

not a perfectly sound type theory playground,
but a pragmatic type system sitting on top of messy real-world JavaScript.

Variance in TS is “good enough” most of the time, but you should be aware of where it leaks.


🌊 How Flow expresses variance explicitly

Quick recap: Flow is a static type checker for JavaScript, originally developed at Facebook (now Meta).

Key characteristics:

  • More strict and soundness-oriented than TS
  • Variance is explicitly annotated
  • Strong type inference
  • Type annotations layered onto plain JS

In very rough terms:

TypeScript focuses on practicality
Flow focuses more on safety

One of Flow’s signature features is explicit variance annotations.

Variance annotations in Flow

In Flow, you write variance directly on type parameters:

Syntax Meaning
+T covariant
-T contravariant
T invariant(no sign)

So in Flow, the author of a type explicitly declares:

“This type parameter is used covariantly / contravariantly / invariantly.”

In TypeScript, the compiler mostly infers this.
In Flow, you annotate it, and Flow checks that your usage matches.


⚛️ How React uses variance in Flow types

React’s internal types were historically written in Flow, and you can still see traces of it:

export type Element<+C> = React$Element<C>;
export type RefSetter<-I> = React$RefSetter<I>;
Enter fullscreen mode Exit fullscreen mode
  • +C is covariant
  • -I is contravariant

Why is Element<+C> covariant?

Because ReactElement is essentially immutable:

  • You construct it
  • You read from it
  • You don’t mutate its props in place

So it’s safe to say that if CatProps <: AnimalProps, then:

ReactElement<CatProps> <: ReactElement<AnimalProps>

Example:

type AnimalProps = { name: string };
type CatProps = { name: string; meow: () => void };

function Cat(props: CatProps) {
  return null as any;
}

const catElement: ReactElement<CatProps> = (
  <Cat name="nyaomaru" meow={() => {}} />
);

// Because of covariance, this is safe:
const parent: ReactElement<AnimalProps> = catElement;
Enter fullscreen mode Exit fullscreen mode
  • A component that accepts “more specific props” can be used where “more general props” are expected
  • The element is read-only — we’re not mutating its props through this type

Why is RefSetter<-I> contravariant?

Because RefSetter receives values (instances) — it doesn’t produce them:

  • It’s on the “write side”
  • That’s a contravariant position

Example:

type HTMLDivRef = (el: HTMLDivElement | null) => void;
type HTMLElementRef = (el: HTMLElement | null) => void;
Enter fullscreen mode Exit fullscreen mode

We have HTMLDivElement <: HTMLElement, and with contravariance:

RefSetter<HTMLElement> <: RefSetter<HTMLDivElement>
Enter fullscreen mode Exit fullscreen mode

So:

  • A callback that can handle any HTMLElement can safely be used where a “div-only” ref setter is expected
  • But the opposite is unsafe: a HTMLDivElement-only ref setter cannot safely accept any HTMLElement

This matches exactly our earlier Handler<T> examples.

Why this matters for reading React’s types

Once you understand Flow’s variance annotations:

  • +C(covariant) on ReactElement<+C> → safe, immutable, read-only
  • -T (contravariant) on RefSetter<-T> → callback parameter, write-only
  • No sign → invariant types where mutation may happen

This makes React’s Flow types much easier to reason about.


🛠️ Where variance actually matters in real code

This may still feel theoretical, but it absolutely shows up in day-to-day work:

  • onClick, onChange, onSubmit → UI event handlers
  • onSuccess, onError → async/API callbacks
  • Exposed “handler” or “listener” APIs in your own libraries

All of these are basically:

type Handler<T> = (value: T) => void;
Enter fullscreen mode Exit fullscreen mode

So:

  • Parameter types → contravariant
  • Return types → covariant
  • Mutable containers → invariant
  • TS callback parameters in methods → often bivariant

When designing public APIs:

  • Prefer wider types for parameters your consumers pass in
  • Avoid exposing raw mutable containers like Box<T> when a ReadonlyBox<T> will do
  • Consider using readonly arrays and properties when you can

In other words:

Variance is a mental checklist for API design, not something you only care about in textbooks.


📌 Wrap-up

We covered a lot, so let’s distill the main points:

  • Variance determines how subtyping of type parameters affects the outer type:
    • Covariant → same direction (usually read-only)
    • Contravariant → opposite direction (usually function parameters)
    • Invariant → no subtyping either way (mutable containers)
    • Bivariant → both directions (convenient but unsound)
  • TypeScript:
    • Treats many generics as “mostly covariant”
    • Makes T[] effectively covariant (even though it should be invariant)
    • Often treats function parameters as bivariant
    • Provides strictFunctionTypes to tighten some of this
  • Flow:
    • Has explicit variance annotations (+T, -T, T)
  • Practically:
    • readonly vs mutable is a variance decision
    • Callback parameter types are a variance decision
    • Whether you expose Box<T> or ReadonlyBox<T> is a variance decision

You don’t need to memorize all the formal rules,
but keeping “covariant / contravariant / invariant / bivariant” in your mental toolbox makes both reading library types and designing your own APIs much easier.

I also made a small repo where you can play with these variance patterns in TypeScript:

👉 https://github.com/nyaomaru/variance-check

Have a nice variance life 🐈‍⬛✨


References

https://en.wikipedia.org/wiki/Type_variance

https://www.typescriptlang.org/docs/handbook/2/generics.html#variance-annotations

https://www.totaltypescript.com/method-shorthand-syntax-considered-harmful

https://www.sandromaglione.com/articles/covariant-contravariant-and-invariant-in-typescript

https://zenn.dev/jay_es/articles/2024-02-13-typescript-variance

https://typescriptbook.jp/reference/generics/variance

https://effectivetypescript.com/2021/05/06/unsoundness/


🐾 Shameless plug

When you write TypeScript, you probably end up writing isXXX guards over and over.
It’s boring and error-prone, so I built a small OSS library to help:

👉 is-kit: a tiny toolkit for building composable, type-safe type guards

https://github.com/nyaomaru/is-kit

If you’re curious, I also wrote about it here:

https://dev.to/nyaomaru/build-isxxx-the-easy-way-meet-is-kit-2hpl

https://dev.to/nyaomaru/building-type-guards-like-lego-blocks-making-reusable-logic-with-is-kit-48e4

https://dev.to/nyaomaru/escaping-the-forest-of-if-statements-building-logical-type-guards-with-is-kit-2db3

Top comments (0)