Definition
Variance is the rule that decides whether the subtype relationship between two types survives once you wrap them in something.
Say Dog is a kind of Animal. Is Dog[] then a kind of Animal[]? Is a function that takes a Dog a kind of function that takes an Animal? The answer differs by position, and variance is what settles it.
There are four cases.
- Covariant — the relationship carries over unchanged. You can put a
Dog[]where anAnimal[]is expected. - Contravariant — the relationship flips. You can put a function taking an
Animalwhere a function taking aDogis expected. - Bivariant — both directions are allowed. Convenient, but not safe.
- Invariant — only the exact same type is allowed.
Why it matters
Without variance rules, code that sails through type checking blows up at runtime. The most famous example is arrays.
const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs; // passes
animals.push(new Cat()); // passes
dogs[1].bark(); // 💥 Cat has no bark
dogs and animals point at the same array. So pushing a cat into animals puts a cat inside dogs too. But as far as the types are concerned dogs only holds dogs, so the call to bark() goes right through.
Here's the key. Reading is safe; allowing writes is not. So variance decides which direction is allowed based on whether a position is a read position or a write position.
In practice, the first place you run into this is usually when you build a component that takes a callback. Exactly which functions a prop like onSelect: (row: Row) => void should accept — and how strictly the compiler checks that — determines how safe your API is.
How it works
Why contravariance flips
The reason function parameter positions are contravariant is more intuitive than it sounds. A function that accepts more can safely sit in a narrower slot.
type DogHandler = (x: Dog) => void;
const handleAnyAnimal = (x: Animal) => console.log(x.name);
const handleOnlyDog = (x: Dog) => x.bark();
const h1: DogHandler = handleAnyAnimal; // ✅ passing a dog is fine
const h2: (x: Animal) => void = handleOnlyDog; // ❌ pass a cat and there's no bark()
handleAnyAnimal can handle any animal, so putting it in a slot where only dogs arrive causes no trouble at all. The other way around, put handleOnlyDog in a slot where any animal can arrive and it breaks the moment a cat shows up.
In other words, parameters are safer the wider they are, and return values are safer the narrower they are. That's why return types are covariant and parameter types are contravariant.
Variance by position
| Position | Variance | Why |
|---|---|---|
| Function return type | Covariant | The receiver only reads |
| Function parameter type | Contravariant | The caller only writes |
readonly property / readonly T[] | Covariant | Read-only, so it's safe |
Mutable property / T[] | Invariant in theory | TypeScript treats it as covariant for convenience |
| Parameters of method shorthand syntax | Bivariant | A deliberate exception for convenience |
The fact that arrays are invariant in theory but treated as covariant by TypeScript is the cause of the accident above. This isn't a bug — it's unsoundness accepted on purpose. Check arrays strictly and most practical code stops compiling.
The spot strictFunctionTypes can't cover
Turn on strictFunctionTypes and function type parameters get checked strictly as contravariant. But method shorthand syntax is not covered by that option.
interface WithMethod {
on(cb: (e: Dog) => void): void; // method shorthand → bivariant, loose
}
interface WithProperty {
on: (cb: (e: Dog) => void) => void; // function property → contravariant, strict
}
The two declarations read almost identically, yet they're checked with different strictness. One character of syntax toggles the protection on and off.
The exception exists because a good number of standard library methods — Array.prototype.push, for one — wouldn't pass the strict check. It's a hole left open for compatibility.
Applying it
Plug the covariance hole with read-only
The cheapest, most effective move.
// risky — the receiver can add elements
function render(items: Item[]) { /* ... */ }
// safe — the write operations aren't in the type at all
function render(items: readonly Item[]) { /* ... */ }
readonly T[] has no push and no splice. Writing was the reason covariance was unsound, so remove writing and the problem disappears.
Take callbacks as function properties
// loose — a wrong handler can slip through
interface Props {
onSelect(row: Row): void;
}
// strict — the parameter is checked contravariantly
interface Props {
onSelect: (row: Row) => void;
}
For public APIs, declare safety-critical callbacks as function properties.
Declaring variance yourself
As of TypeScript 4.7 you can pin your intent by marking generic parameters with in (contravariant) / out (covariant).
interface Producer<out T> { get(): T }
interface Consumer<in T> { set(value: T): void }
interface Store<in out T> { get(): T; set(value: T): void }
It doesn't replace inference — it's for nailing down your intent and having it verified. Mark it wrong and the compiler tells you "that position doesn't have that variance." In libraries with big types it also cuts inference cost.
Pick constraints that don't lean on variance
When you write a type constraint, it's risky if the reason it passes is "because the compiler judges this field contravariant." Switch the field to method shorthand and the bivariance exception kicks in, collapsing the whole basis.
// depends on a variance judgment — silently collapses if the syntax changes
function f<T extends readonly ColumnDef<never>[]>(cols: T) {}
// demands structure only — stable regardless of syntax
function f<T extends readonly { readonly id: string }[]>(cols: T) {}
Trade-offs
The stricter you go, the less code compiles. Make everything invariant and it's theoretically perfect, but most practical code stops working and nobody uses it. TypeScript leaving arrays covariant and keeping the bivariance exception for methods is the result of picking that balance.
Adopting readonly across the board definitely raises safety, but drop it into an existing codebase and the edits spread pretty wide. Applying it to new public APIs first and widening gradually is the realistic path.
in/out annotations make intent clear, but the moment you add them the generic's usable positions get constrained. When you later want to use it in a return position too, in becomes the obstacle. Best kept for library types you'll maintain for a long time.
When not to use it
- Short internal helper functions in an app. No need to design all the way down to variance. If there are a handful of call sites and they're all in the same file, plain concrete types are easier to read and just as safe.
- Trying to guarantee runtime safety with variance alone. Variance is a compile-time rule. Data that comes in from outside — API responses,
localStoragevalues — has to be checked with a runtime parser, variance or not. - Complicating your types to work around the bivariance exception. If loose method shorthand is the problem, the answer isn't a clever generic — it's switching to a function property.
Common mistakes
- Passing
Dog[]asAnimal[]and letting the receiver add to it. The classic accident. Addreadonlywhen you pass it and it never happens in the first place. - Believing callbacks are safe because
strictFunctionTypesis on. If it's declared with method shorthand, that option doesn't apply. You have to check the declaration syntax yourself. - Using constraints that rely on a variance judgment. If the reason something passes is the compiler's judgment, then when the syntax changes the protection quietly disappears with no error.
- Sprinkling
in/outeverywhere purely for performance. An annotation is a contract. You'll be blocked later when you try to use that generic in a different position. - Reporting that the behavior is correct because the types passed. Variance is a cheap filter, not a safety proof. TypeScript is deliberately unsound in places.
Related concepts
- derive-types-from-values — why you pick constraints that don't lean on variance when deriving types from values
- generic-context-factory — how strictly parameter positions get checked when you put callbacks in a generic context
- type-level-testing — using compiler evidence to confirm a variance judgment actually blocks misuse