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>;
“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’stype 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 fullFlowtutorial here. I’ll only touchFlowenough 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 ofList<Animal>? - Can we use
Handler<Animal>where aHandler<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 (
TypeScriptfoot-gun)
- Both directions are allowed (
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()
Here we only read from the box, so it’s safe to treat a ReadonlyBox<Cat> as a ReadonlyBox<Animal>. That’s covariance.
- Top:
Cat→Animal(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
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:
Cat→Animal - Bottom:
Handler<Animal>→Handler<Cat>(reverse direction → contravariant)
⛔ Invariant: “No subtyping either way”
Even if Cat <: Animal , with invariance:
Invariant:
F<Cat>andF<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;
Why?
- If you treat
Box<Cat>asBox<Animal>:- You could write a plain
Animalinto it - But someone else might assume it still only contains
Cat
- You could write a plain
- If you treat
Box<Animal>asBox<Cat>:- You might pull an
Animalfrom it and assume it’s always aCat
- You might pull an
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:
Cat→Animal - Bottom: no arrow between
Box<Cat>andBox<Animal>→ invariant
🔁 Bivariant: “Both directions are OK (and that’s exactly the problem)” — TypeScript’s hole
Given Cat <: Animal, bivariance is:
Bivariant:
F<Cat>andF<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
-
MouseEventis a subtype ofEvent - If both directions are allowed, you can pass the “wrong” handler
-
TypeScriptchooses practicality over strict safety here
- Top:
MouseEvent→Event - 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
JavaScriptAPIs - Massive existing
JavaScriptcodebases -
Browser/DOMAPIs 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
butTypeScripttreats 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
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 existingJavaScriptcode
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
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”, likeT[].
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
strictFunctionTypesis disabled
Let’s see why that’s a problem.
Why should parameters be contravariant?
type Handler<T> = (value: T) => void;
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>
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
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
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
}
}
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
-
strictFunctionTypeshelps, 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:
TypeScriptfocuses on practicality
Flowfocuses 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>;
-
+Cis covariant -
-Iis 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;
- 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;
We have HTMLDivElement <: HTMLElement, and with contravariance:
RefSetter<HTMLElement> <: RefSetter<HTMLDivElement>
So:
- A callback that can handle any
HTMLElementcan safely be used where a “div-only” ref setter is expected - But the opposite is unsafe: a
HTMLDivElement-only ref setter cannot safely accept anyHTMLElement
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) onReactElement<+C>→ safe, immutable, read-only -
-T(contravariant) onRefSetter<-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;
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 aReadonlyBox<T>will do - Consider using
readonlyarrays 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
strictFunctionTypesto tighten some of this
- Flow:
- Has explicit variance annotations (
+T,-T,T)
- Has explicit variance annotations (
- Practically:
-
readonlyvs mutable is a variance decision - Callback parameter types are a variance decision
- Whether you expose
Box<T>orReadonlyBox<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






Top comments (0)