Overview
Variance answers one question: if Cat is a subtype of Animal, what is
List<Cat> to List<Animal>? Covariance preserves the direction
(List<Cat> usable as List<Animal>), contravariance reverses it
(Handler<Animal> usable as Handler<Cat>), and invariance allows neither. The rules
are not type-system trivia: they are the Liskov substitution principle made mechanical —
a variance annotation is a machine-checked promise about where substitution stays safe —
and interface segregation is what makes the annotations possible at all.
Key points
- The direction is set by data flow. Positions that produce values (return types,
read-only collections) can be covariant; positions that consume values (parameters)
must be contravariant. A function type is therefore contravariant in its inputs and
covariant in its output:
(Animal) -> Catis a perfectly good(Cat) -> Animal. - The tie to Liskov substitution: LSP says a subtype may not strengthen preconditions (so overridden methods may widen parameter types — contravariance) and may not weaken postconditions (so they may narrow return types — covariance). Variance rules are LSP's pre/postcondition clauses applied to signatures; a variance error is a substitution that would break a caller.
- The tie to interface segregation: a type that both produces and consumes
Tis forced invariant. Splitting it into role interfaces — a producer (covariant) and a consumer (contravariant) — is ISP applied to variance:Iterable<out T>/Comparator<in T>in Kotlin only exist because the fat interface was segregated. Fat interfaces don't just couple clients to methods they don't use; they destroy variance. - Use-site vs. declaration-site: Java annotates at use site with wildcards —
PECS, Producer
extends, Consumersuper— pushing the burden onto every signature; Kotlin, C#, and Scala declareout/in(+/-) once on the type, which only works when the interface has a single role (ISP again). - The classic unsoundness: Java and C# arrays are covariant, so
Animal[] a = new Cat[1]; a[0] = new Dog()compiles and fails at runtime (ArrayStoreException) — the cautionary tale for covariance on a read-write type. TypeScript accepts method-parameter bivariance for pragmatism (strictFunctionTypestightens standalone function types, methods stay loose). - Rule of thumb: read-only → covariant, write-only → contravariant, read-write → invariant (or segregate until the roles separate).
Examples
interface Producer<out T> { fun next(): T } // covariant: T only flows out
interface Consumer<in T> { fun accept(item: T) } // contravariant: T only flows in
val cats: Producer<Cat> = catShelter
val animals: Producer<Animal> = cats // ok — a Cat producer produces Animals
val animalSink: Consumer<Animal> = zoo
val catSink: Consumer<Cat> = animalSink // ok — an Animal consumer accepts Cats
// Java, use-site variance — PECS
void copy(List<? extends Animal> src, List<? super Cat> dst)
Practice
- type-challenges (source) — warm-up reps at the type level; the variance-flavored puzzles sharpen the eye for which positions produce and which consume.
- Variance workout — LSP & ISP through the type checker (exercise) — the six drills as one project: the array-covariance trap, Rectangle/Square under property tests, the PECS drill, the ISP→variance kata, strictFunctionTypes koans, and LSP as executable contract tests (see Testing strategies).
Related
- Clean code — home of SOLID; variance is the L and the I doing type-level work.
- Coupling & cohesion — a variance annotation narrows the contract clients can couple to.
- Deep dive TypeScript — flags variance and method bivariance as its to-explore; this note is that thread.
- Deep dive Java — wildcards and PECS are the use-site dialect of these rules.