This content originally appeared on DEV Community and was authored by nyaomaru
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
This content originally appeared on DEV Community and was authored by nyaomaru





