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 (6)
This is a really clear and thoughtful breakdown ā I love how you connect the theory to real TypeScript and React examples, it made variance finally click for me. It also makes me want to be more intentional about readonly types and callback design in my own APIs.
Thank you! Iām really glad it clicked for you.
readonlytypes and callback design are some of the clearest places where variance actually matters. šøSome comments may only be visible to logged-in visitors. Sign in to view all comments.