Part 2 · 2 chapters · ~20 min
Generics and Variance
Variance as the direction a generic bends (covariant outputs, contravariant inputs, invariant both), the deliberate method exception and the array unsoundness, measured variance and its annotations, designing with readonly views, and generic design: constraints to what the body uses, inference positions, defaults, related parameters, overloads versus conditional returns, and the smell test at call sites.
5
Variance: which way a generic bends
code
// variance, demonstrated: what the checker accepts and what it refuses, and the method exception
type Animal = { name: string }; type Dog = Animal & { bark(): void }
declare const dog: Dog; declare const animal: Animal
// covariant: T only in outputs
type Getter<out T> = { get(): T } // `out` declares what the checker would measure
declare const getDog: Getter<Dog>
const getAnimal: Getter<Animal> = getDog // ✓ reading a Dog where an Animal is expected
// contravariant: T only in inputs (function-property syntax: strict)
type Setter<in T> = { set: (v: T) => void }
declare const setAnimal: Setter<Animal>
const setDog: Setter<Dog> = setAnimal // ✓ a setter for any Animal accepts a Dog
// const setAnimal2: Setter<Animal> = setDog // ✗ a Dog setter cannot take a Cat
// invariant: both
type Cell<in out T> = { get(): T; set: (v: T) => void }
declare const cellDog: Cell<Dog>
// const cellAnimal: Cell<Animal> = cellDog // ✗ set would accept a Cat
// the method exception: method syntax is bivariant even under strictFunctionTypes
type SetterM<T> = { set(v: T): void }
declare const setDogM: SetterM<Dog>
const setAnimalM: SetterM<Animal> = setDogM // ✓ (!) accepted: the deliberate unsoundness that keeps Array<T> ergonomic
const dogs: Dog[] = [dog]
const animals: Animal[] = dogs // ✓ arrays are treated as covariant; animals.push({ name: 'cat' }) now puts a non-Dog in dogs
// design: expose a covariant view; keep mutation behind an invariant surface
type Store<S> = { read: () => S; subscribe: (fn: (s: S) => void) => () => void } // covariant in S for readers
type Writer<S> = { write: (s: S) => void } // contravariant; handed only to the owner
// a readonly selector (readonly S) composes across consumers; a mutable cell does notoutputs follow the lattice, inputs reverse it
- Covariant: T only in output positions (a getter, a return, a readonly property, Promise, ReadonlyArray):
Getter<Dog> ≤ Getter<Animal>, because reading a Dog where an Animal is expected is fine. - Contravariant: T only in input positions (a setter's parameter, a function parameter):
Setter<Animal> ≤ Setter<Dog>, because a setter for any Animal accepts a Dog. Function parameters are contravariant understrictFunctionTypes. - Invariant: T in both: only equal type arguments relate. Mutable arrays are invariant by the rules and treated as covariant for ergonomics: the known unsoundness where a
Dog[]assigned toAnimal[]can receive a Cat. - The method exception: method syntax (
set(v: T): void) is checked bivariantly on purpose so Array and old event interfaces keep working; function-property syntax (set: (v: T) => void) is strict. Write your own interfaces with function-property syntax when you want the check. - Measured variance: the checker probes each type parameter's variance from the definition and caches it, so comparing
Box<X>toBox<Y>compares X and Y in that direction instead of expanding the structures (a major performance lever: part 5).in,outandin outannotations declare it, document intent and skip the probe. - Design with it: expose readonly, covariant views (ReadonlyArray, Readonly mapped types, getter-only interfaces) that compose across consumers; keep mutation behind narrow, invariant surfaces handed only to owners. An event payload typed mutable is invariant and nobody can subscribe with a wider handler (the FSD course M1 v3's readonly selectors exist for this).
VARIANCE: WHICH WAY A GENERIC BENDS
covariant outputs, contravariant inputs, invariant both, and the method exception
swipe the figure sideways, or tap expand for full screen
1/6
covariant
Covariant: type Getter = { get(): T }. Getter ≤ Getter: whoever calls get() expecting an Animal receives a Dog, which is an Animal. readonly T[] is covariant; Promise is covariant; a function's return type is covariant. Reading is safe in the direction of the lattice.
6
Constraints, defaults and where a parameter belongs
a generic earns its parameters by inferring them
- Constraints:
T extends Xis what the body uses, not more (over-constraining rejects valid callers; under-constraining fails the body); the return carries the caller's type back;K extends keyof Tties a key parameter to its object. - Inference positions: a parameter is inferred from arguments that mention it; one that appears only in the return must be given (
useState<string>()); callback parameters are inferred last, after contextual typing, somap<T, U>infers U from the callback's return. - Defaults:
Result<T, E = Error>makes the common case terse and the rare case explicit; a default must satisfy the constraint and applies only when inference finds no candidate. - Related parameters: each extra parameter is a thing to infer and a thing to explain in an error.
update<T, K extends keyof T, V extends T[K]>works with confusing errors;v: T[K]in the parameter position expresses V without the parameter; an object parameter is clearer still. - Overloads versus conditional returns: overloads give exact return types per call shape, checked top-down with a hidden implementation; a conditional return is one signature whose body needs a cast and whose errors are worse. Overloads for a few shapes; a conditional when the shapes are many and regular (part 3).
- The smell test, read at call sites: explicit type arguments everywhere means inference failed and the signature is wrong; errors naming parameters the caller never wrote means the constraints do too much; a generic that exists to avoid two functions sharing no logic should be two functions.
the exercise
Find the generic in your codebase with the most type parameters. For each parameter, name the argument it is inferred from; the ones with no answer are the ones to remove.
CONSTRAINTS, DEFAULTS AND WHERE A TYPE PARAMETER BELONGS
the rules for a generic that infers well, errors clearly and does not become a puzzle
swipe the figure sideways, or tap expand for full screen
1/6
constraints
Constraints: function longest(a: T, b: T): T. The constraint is what the body uses (length), so strings, arrays and anything with a length work; the return keeps the argument's type. Over-constraining (T extends string[]) rejects valid callers; under-constraining (no constraint) makes the body fail to compile. keyof constraints (K extends keyof T) tie a key parameter to an object parameter: get(o: T, k: K): T[K].