Forget TypeScript syntax for a minute. Picture an orchard stall with two crates: one labelled "apples only", one labelled "any fruit". Every apple counts as fruit, so the apple crate is narrower and the fruit crate is wider.
Variance answers one question: when can one crate stand in for the other? It depends on what people do with the crate.
A dispenser that only hands fruit out swaps narrow for wide. Offer an apple dispenser where fruit is expected and everyone stays happy, since every apple qualifies as fruit. Type theory calls that direction covariant.
A collection bin that only takes fruit in swaps the other way. Point apple carriers at the any-fruit bin and it works. Hand a mixed harvest crew an apples-only bin and the first banana gets rejected. A wider acceptor can replace a narrower one, never the reverse. That flipped direction is contravariant.
A crate that does both swaps neither way. An apple crate lent as a fruit crate collects a stray banana. A fruit crate lent as an apple crate may hand an orange to someone expecting an apple. Receiving and producing together locks the type in place. That is invariant.
The same three shapes show up directly in types.
fruit-box.ts
type Box<T> = {
get: () => T; // hands values out (Case 1)
put: (item: T) => void; // takes values in (Case 2)
};type Box<T> = {
get: () => T; // hands values out (Case 1)
put: (item: T) => void; // takes values in (Case 2)
};A shape with only get acts like the dispenser, only put like the collection bin, and both like the busy market crate: fixed to exactly one fruit kind. Functions split the same way, with some positions handing values out and others taking them in.
problem-fruit.ts
type Fruit = {
name: string;
weightKg: number;
};
type Apple = {
name: "apple";
weightKg: number;
crunch: boolean;
};
type A =
((fruit: Fruit) => void) extends ((fruit: Apple) => void)
? true
: false;type Fruit = {
name: string;
weightKg: number;
};
type Apple = {
name: "apple";
weightKg: number;
crunch: boolean;
};
type A =
((fruit: Fruit) => void) extends ((fruit: Apple) => void)
? true
: false;The type of A in the above situation will be true.
If (fruit: Fruit) => void is assignable to (fruit: Apple) => void, doesn't it mean that Fruit should be assignable to Apple? But we know this isn't true. Not every fruit crunches like an apple!
A concrete kitchen makes this clearer than set diagrams.
Function Types and the Upside Down
Picturing types as sets works until functions enter. Functions hand values back and ask for values. The clearest way to judge them is to track who calls whom.
The juice stand version looks like this.
quiz-fruit.ts
type Quiz =
(() => Apple) extends (() => Fruit)
? true
: false; // truetype Quiz =
(() => Apple) extends (() => Fruit)
? true
: false; // truejuice-stand.ts
async function makeJuice(pick: () => Fruit) {
const fruit = pick();
await blend({ kind: "juice", from: fruit.name });
return "ok";
}async function makeJuice(pick: () => Fruit) {
const fruit = pick();
await blend({ kind: "juice", from: fruit.name });
return "ok";
}You run a juice stand. Your makeJuice station grows no produce. It asks the caller for a pick callback of type () => Fruit.
You notice a helper in the codebase that always walks to the orchard and brings back an apple:
pick-apple.ts
function pickApple(): Apple {
return { name: "apple", weightKg: 0.2, crunch: true };
}function pickApple(): Apple {
return { name: "apple", weightKg: 0.2, crunch: true };
}You want to pass pickApple to makeJuice.
call-site.ts
makeJuice(pickApple); // does this type-check?makeJuice(pickApple); // does this type-check?makeJuice needs a fruit from its callback. Every apple qualifies, so this type-checks.
call-site-fixed.ts
makeJuice(pickApple); // ✓ OKmakeJuice(pickApple); // ✓ OKThat gives the rule for returns: a function of type () => A is assignable to a function of type () => B if A is assignable to B.
() => Ais assignable to() => BifAis assignable toB
Return types keep the same direction as normal assignability. Type theory calls return types covariant. That matches Case 1 from the stall. Values only flow out.
Parameters flip.
Bland juice worries you. You ask the picker for a basket weight so the blend varies:
juice-stand-v2.ts
declare function makeJuice(
pick: (basketKg: number) => Fruit
// ^ now takes a number
): Promise<"ok">;declare function makeJuice(
pick: (basketKg: number) => Fruit
// ^ now takes a number
): Promise<"ok">;You consider narrowing pickApple to 1 | 2 because it always returns an apple and 1 | 2 fits inside number.
pick-apple-v2.ts
function pickApple(basketKg: 1 | 2): Apple {
return { name: "apple", weightKg: basketKg, crunch: true };
}
makeJuice(pickApple);
// ~~~~~~~~~ ✗ Error: not assignable.
// Found: '(basketKg: 1 | 2) => Apple'.
// Wanted: '(basketKg: number) => Fruit'.function pickApple(basketKg: 1 | 2): Apple {
return { name: "apple", weightKg: basketKg, crunch: true };
}
makeJuice(pickApple);
// ~~~~~~~~~ ✗ Error: not assignable.
// Found: '(basketKg: 1 | 2) => Apple'.
// Wanted: '(basketKg: number) => Fruit'."That banana doesn't fit in the apple box" whispers the type checker...
This fails. The caller picks the basket weight, not pickApple. The makeJuice station can pass any number, not only 1 or 2.
juice-stand-impl.ts
async function makeJuice(pick: (basketKg: number) => Fruit) {
// `pick` can receive any number:
const fruit = pick(1290921);
// ...
}async function makeJuice(pick: (basketKg: number) => Fruit) {
// `pick` can receive any number:
const fruit = pick(1290921);
// ...
}Your callback consumes the number instead of providing it, so it must handle all numbers.
Accepting a wider input works instead.
pick-apple-fixed.ts
function pickApple(basketKg: number | string): Apple {
return { name: "apple", weightKg: 2, crunch: true };
}
makeJuice(pickApple); // ✓ OKfunction pickApple(basketKg: number | string): Apple {
return { name: "apple", weightKg: 2, crunch: true };
}
makeJuice(pickApple); // ✓ OKIf pickApple supports numbers and strings, it works where only numbers ever arrive.
The argument rule:
(arg: A) => Tis assignable to(arg: B) => TifBis assignable toA
Arguments invert the direction. That matches Case 2 from the stall. Values only flow in.
Providers and Consumers
Most code provides values. You assign them to variables, pass them to functions, or store them on objects. Providing a subtype of what is expected works.
providers-ok.ts
// These are waiting for `Fruit` values we should assign:
let snack: Fruit;
let crate: { item: Fruit };
let eat = (fruit: Fruit) => {};
declare const apple: Apple;
snack = apple; // ✓ OK
crate = { item: apple }; // ✓ OK
eat(apple); // ✓ OK// These are waiting for `Fruit` values we should assign:
let snack: Fruit;
let crate: { item: Fruit };
let eat = (fruit: Fruit) => {};
declare const apple: Apple;
snack = apple; // ✓ OK
crate = { item: apple }; // ✓ OK
eat(apple); // ✓ OKProviding a wider type fails.
providers-error.ts
// We can't provide a supertype:
declare const mystery: Fruit | Bread;
snack = mystery; // ✗ Error
crate = { item: mystery }; // ✗ Error
eat(mystery); // ✗ Error// We can't provide a supertype:
declare const mystery: Fruit | Bread;
snack = mystery; // ✗ Error
crate = { item: mystery }; // ✗ Error
eat(mystery); // ✗ ErrorThere you act as the provider because you hand values over. Function parameters reverse the role. Your code requests values, so it acts as the consumer.
consumers-error.ts
// These consume fruits:
let handler = (fruit: Fruit) => {};
let stall: { taste: (fruit: Fruit) => void };
let withTaster = (cb: (fruit: Fruit) => void) => {};
// We can't use a narrower fruit in the argument position:
declare const tasteAppleOnly: (fruit: Apple) => void;
handler = tasteAppleOnly; // ✗ Error
stall = { taste: tasteAppleOnly }; // ✗ Error
withTaster(tasteAppleOnly); // ✗ Error// These consume fruits:
let handler = (fruit: Fruit) => {};
let stall: { taste: (fruit: Fruit) => void };
let withTaster = (cb: (fruit: Fruit) => void) => {};
// We can't use a narrower fruit in the argument position:
declare const tasteAppleOnly: (fruit: Apple) => void;
handler = tasteAppleOnly; // ✗ Error
stall = { taste: tasteAppleOnly }; // ✗ Error
withTaster(tasteAppleOnly); // ✗ ErrortasteAppleOnly breaks when the caller hands it a banana. It only knows apples. Widening the input fixes it.
consumers-ok.ts
// We *can* use a supertype in the argument position:
declare const tasteAnything: (food: Fruit | Bread) => void;
handler = tasteAnything; // ✓ OK
stall = { taste: tasteAnything }; // ✓ OK
withTaster(tasteAnything); // ✓ OK// We *can* use a supertype in the argument position:
declare const tasteAnything: (food: Fruit | Bread) => void;
handler = tasteAnything; // ✓ OK
stall = { taste: tasteAnything }; // ✓ OK
withTaster(tasteAnything); // ✓ OKIf tasteAnything handles fruit or bread, it works where only fruit arrives.
Type theory calls the argument position a contravariant position. The standard direction is called covariant.
"Function arguments are a contravariant position. All other positions are covariant."
Variance describes the direction assignability moves. It belongs to the position where a type sits, not to the type alone.
Variance and Type Parameters
Callbacks make variance feel obvious. Generic crates hide it.
A concrete shipment shows the difference.
ship-fruit.ts
const shipFruit = async (options: Shipment<Fruit>) => {
// load a truck...
};
const packApples = async (options: Shipment<Apple>) => {
return shipFruit(options);
/* ^
Are we allowed to pass `options` to shipFruit?
*/
};const shipFruit = async (options: Shipment<Fruit>) => {
// load a truck...
};
const packApples = async (options: Shipment<Apple>) => {
return shipFruit(options);
/* ^
Are we allowed to pass `options` to shipFruit?
*/
};We are trying to assign a Shipment<Apple> to a Shipment<Fruit>. The answer depends on where T sits.
When T marks a value you provide, the assignment holds because Apple fits inside Fruit.
shipment-covariant.ts
type Shipment<T extends { name: string }> = {
label: { contents: T };
/* ^
covariant */
};type Shipment<T extends { name: string }> = {
label: { contents: T };
/* ^
covariant */
};Here, the T parameter sits in a covariant position, an object property, so this assignment type-checks.
shipment-covariant-ok.ts
const packApples = async (options: Shipment<Apple>) => {
return shipFruit(options);
/* ^ ✓ OK
`Shipment<Apple>`
is assignable to `Shipment<Fruit>`. */
};const packApples = async (options: Shipment<Apple>) => {
return shipFruit(options);
/* ^ ✓ OK
`Shipment<Apple>`
is assignable to `Shipment<Fruit>`. */
};A different Shipment shape changes the answer.
shipment-contravariant.ts
type Shipment<T extends { name: string }> = {
onArrive: (item: T) => void;
/* ^
contravariant */
};type Shipment<T extends { name: string }> = {
onArrive: (item: T) => void;
/* ^
contravariant */
};T now marks a value you consume. Narrowing to apples fails because the truck must handle any fruit that arrives.
shipment-contravariant-error.ts
const packApples = async (options: Shipment<Apple>) => {
return shipFruit(options);
/* ^ ✗ Error
`Shipment<Apple>`
is *not* assignable to `Shipment<Fruit>`! */
};const packApples = async (options: Shipment<Apple>) => {
return shipFruit(options);
/* ^ ✗ Error
`Shipment<Apple>`
is *not* assignable to `Shipment<Fruit>`! */
};Two snippets that look almost identical can check in opposite ways.
Two kinds remain: invariance and bivariance.
Invariance
Invariance means that two instances of a generic type can't be assigned in either direction. It happens when a type parameter sits in both a covariant and a contravariant position.
A picker UI shows this well because it displays the current choice and reports the next choice.
fruit-picker.tsx
type FruitPicker<T> = {
selected: T;
// ^ covariant
onSelect: (value: T) => void;
// ^ contravariant
};
declare const FruitBowl: (
props: FruitPicker<Fruit>
) => JSX.Element;type FruitPicker<T> = {
selected: T;
// ^ covariant
onSelect: (value: T) => void;
// ^ contravariant
};
declare const FruitBowl: (
props: FruitPicker<Fruit>
) => JSX.Element;Since the T parameter is used both as a property and inside a callback, subtyping breaks in both directions.
fruit-picker-subtype.tsx
// `Apple` is a subtype of `Fruit`.
const props: FruitPicker<Apple> = {
selected: { name: "apple", weightKg: 0.2, crunch: true },
onSelect: (value) => keepApple(value),
};
FruitBowl(props);
/* ~~~~~
* ✗ Not assignable because `props.onSelect` isn't.
* A handler for `Apple` can't handle every `Fruit`.
*/// `Apple` is a subtype of `Fruit`.
const props: FruitPicker<Apple> = {
selected: { name: "apple", weightKg: 0.2, crunch: true },
onSelect: (value) => keepApple(value),
};
FruitBowl(props);
/* ~~~~~
* ✗ Not assignable because `props.onSelect` isn't.
* A handler for `Apple` can't handle every `Fruit`.
*/A wider choice fails on the other side.
fruit-picker-supertype.tsx
// `Fruit | Bread` is a supertype of `Fruit`.
const props: FruitPicker<Fruit | Bread> = {
selected: { name: "loaf", baked: true },
onSelect: (value) => keepFood(value),
};
FruitBowl(props);
/* ~~~~~
* ✗ Not assignable because `props.selected` isn't.
* A loaf of bread is not a `Fruit`.
*/// `Fruit | Bread` is a supertype of `Fruit`.
const props: FruitPicker<Fruit | Bread> = {
selected: { name: "loaf", baked: true },
onSelect: (value) => keepFood(value),
};
FruitBowl(props);
/* ~~~~~
* ✗ Not assignable because `props.selected` isn't.
* A loaf of bread is not a `Fruit`.
*/You meet this error whenever a component both shows a value and reports a new one. The box fills and empties, so the type locks.
Bivariance
Bivariance is the opposite of invariance. Assignability works in both directions. A type that ignores its parameter accepts both directions.
bivariant.ts
type CrateTag<T> = { kind: "crate-tag" };
declare let x: CrateTag<Apple>;
declare let y: CrateTag<Fruit>;
x = y; // ✓ OK
y = x; // ✓ OKtype CrateTag<T> = { kind: "crate-tag" };
declare let x: CrateTag<Apple>;
declare let y: CrateTag<Fruit>;
x = y; // ✓ OK
y = x; // ✓ OKIn Strict Mode, bivariance stays rare because type parameters seldom go unused. Methods are the exception. TypeScript still checks method parameters bivariantly with strictFunctionTypes on. Function-type properties such as (value: T) => void check strictly. Shorthand methods such as onSelect(value: T): void stay bivariant.
Explicit Variance with in and out
Generic classes look covariant by default when they should not. The orchard version shows the hole.
crate.ts
type Bread = { name: string; baked: boolean };
type Banana = {
name: "banana";
weightKg: number;
lengthCm: number;
};
class Crate<T> {
constructor(private stock: T) {}
stockUp(item: T) {
this.stock = item;
}
handOver(): T {
return this.stock;
}
}
const appleCrate = new Crate<Apple>({
name: "apple",
weightKg: 0.2,
crunch: true,
});
appleCrate.stockUp({
name: "apple",
weightKg: 0.3,
crunch: true,
});
appleCrate.handOver(); // an `Apple`.type Bread = { name: string; baked: boolean };
type Banana = {
name: "banana";
weightKg: number;
lengthCm: number;
};
class Crate<T> {
constructor(private stock: T) {}
stockUp(item: T) {
this.stock = item;
}
handOver(): T {
return this.stock;
}
}
const appleCrate = new Crate<Apple>({
name: "apple",
weightKg: 0.2,
crunch: true,
});
appleCrate.stockUp({
name: "apple",
weightKg: 0.3,
crunch: true,
});
appleCrate.handOver(); // an `Apple`.A Crate holds stock. T appears as an argument and as a return. That pair should make the parameter invariant.
Assigning a narrow crate to a wider slot exposes the hole.
crate-covariant-bug.ts
// ...wait, this type-checks?
let mixedCrate: Crate<Fruit> = appleCrate; // ✓ OK// ...wait, this type-checks?
let mixedCrate: Crate<Fruit> = appleCrate; // ✓ OKAn apple crate now passes as a fruit crate. The inferred variance is wrong, and this causes real damage.
crate-unsound.ts
mixedCrate.stockUp({
name: "banana",
weightKg: 0.25,
lengthCm: 18,
});
appleCrate.handOver(); // a banana typed as `Apple`!mixedCrate.stockUp({
name: "banana",
weightKg: 0.25,
lengthCm: 18,
});
appleCrate.handOver(); // a banana typed as `Apple`!mixedCrate aliases appleCrate, so stocking a banana through the wide alias corrupts the narrow crate. Methods cause the mismeasurement. TypeScript measures variance structurally, and a method such as stockUp(item: T) does not count as contravariant, so Crate measures as covariant. The fix is an explicit variance annotation.
TypeScript 4.7 added explicit variance with the in and out keywords.
Use out for a covariant parameter.
covariant.ts
type Covariant<out T> = { item: T };
// ^
declare const x1: Covariant<Apple | Banana>;
const x2: Covariant<Fruit> = x1;
// ~~ ✓ OK
const x3: Covariant<Apple> = x1;
// ~~ ✗ Error: type 'Covariant<Apple | Banana>'
// is not assignable to type 'Covariant<Apple>'.type Covariant<out T> = { item: T };
// ^
declare const x1: Covariant<Apple | Banana>;
const x2: Covariant<Fruit> = x1;
// ~~ ✓ OK
const x3: Covariant<Apple> = x1;
// ~~ ✗ Error: type 'Covariant<Apple | Banana>'
// is not assignable to type 'Covariant<Apple>'.Use in for a contravariant parameter.
contravariant.ts
type Contravariant<in T> = { onPick: (value: T) => void };
// ^
declare const x1: Contravariant<Apple | Banana>;
const x2: Contravariant<Fruit> = x1;
// ~~ ✗ Error: type 'Contravariant<Apple | Banana>'
// is not assignable to type 'Contravariant<Fruit>'.
const x3: Contravariant<Apple> = x1;
// ~~ ✓ OKtype Contravariant<in T> = { onPick: (value: T) => void };
// ^
declare const x1: Contravariant<Apple | Banana>;
const x2: Contravariant<Fruit> = x1;
// ~~ ✗ Error: type 'Contravariant<Apple | Banana>'
// is not assignable to type 'Contravariant<Fruit>'.
const x3: Contravariant<Apple> = x1;
// ~~ ✓ OKUse both for an invariant parameter.
invariant.ts
type Invariant<in out T> = {
selected: T;
onSelect: (value: T) => void;
};
// ^ ^
declare const x1: Invariant<Apple | Banana>;
const x2: Invariant<Fruit> = x1;
// ~~ ✗ Error
const x3: Invariant<Apple> = x1;
// ~~ ✗ Errortype Invariant<in out T> = {
selected: T;
onSelect: (value: T) => void;
};
// ^ ^
declare const x1: Invariant<Apple | Banana>;
const x2: Invariant<Fruit> = x1;
// ~~ ✗ Error
const x3: Invariant<Apple> = x1;
// ~~ ✗ ErrorTypeScript infers variance for generics and interfaces. Annotate class parameters explicitly. The safe Crate looks like this.
crate-fixed.ts
// invariant
// v
class Crate<in out T> {
constructor(private stock: T) {}
stockUp(item: T) { // <-- contravariant
this.stock = item;
}
handOver(): T { // <-- covariant
return this.stock;
}
}
const appleCrate = new Crate({
name: "apple",
weightKg: 0.2,
crunch: true,
});
const mixedCrate: Crate<Fruit> = appleCrate;
// ~~~~~~~~~~ ✗ Error. Good, the assignment is rejected
const tinyCrate: Crate<Apple> =
new Crate<Fruit>({ name: "pear", weightKg: 0.3 });
// ~~~~~~~~~ ✗ Error. This one too// invariant
// v
class Crate<in out T> {
constructor(private stock: T) {}
stockUp(item: T) { // <-- contravariant
this.stock = item;
}
handOver(): T { // <-- covariant
return this.stock;
}
}
const appleCrate = new Crate({
name: "apple",
weightKg: 0.2,
crunch: true,
});
const mixedCrate: Crate<Fruit> = appleCrate;
// ~~~~~~~~~~ ✗ Error. Good, the assignment is rejected
const tinyCrate: Crate<Apple> =
new Crate<Fruit>({ name: "pear", weightKg: 0.3 });
// ~~~~~~~~~ ✗ Error. This one tooThat's enough with the theory
Quick reference:
- Return types are covariant:
() => Ais assignable to() => BifAis assignable toB. Handing over an apple where fruit is wanted is fine. - Parameters are contravariant:
(arg: A) => voidis assignable to(arg: B) => voidifBis assignable toA. Accepting fruit-or-bread where only fruit arrives is fine, accepting only apples is not. - Providers can narrow, consumers must widen. When you assign, pass, or return a value, you are a provider. When you declare a function parameter, you are a consumer.
- Generics inherit variance from where
Tappears: object properties and return types are covariant, function arguments are contravariant. - Both = invariant (like
FruitPicker<T>withselected: T+onSelect: (value: T) => void), neither = bivariant (likeCrateTag<T>). - Use
in/out(TS 4.7+) to make variance explicit, especially for classes likeCrate<T>where TypeScript's measurement is unsound by default.
Check whether T flows out, in, or both before you change a generic.
