Generics, Variance & Type Erasure
Parametric polymorphism and what it costs at run time — erasure versus reification versus monomorphization — and variance: why a list of dogs is not a list of animals, and how PECS, declaration-site variance and use-site wildcards each express the same rule.
Generics let one implementation serve many types with the type relationships checked once, at definition, instead of at every use. The two questions that make generics hard are independent: what survives to run time (erasure, reification, or monomorphization) and how subtyping of the parameter relates to subtyping of the generic type (variance). Most generic-code confusion is one of those two wearing a language-specific costume.
What survives to run time #
| Strategy | Languages | Run-time representation | Consequence |
|---|---|---|---|
| Erasure | Java, Kotlin/JVM, Scala, TypeScript | type argument discarded; one class for all instantiations | no new T[], no instanceof List<String>, no overload on List<Int> vs List<String> |
| Reification | C#, CLR generics | type argument present in metadata; JIT specializes value types, shares one body for reference types | typeof(T), is List<string>, new T[] all work |
| Monomorphization | Rust, C++, Go (partially, via GC-shape stenciling) | a separate compiled body per instantiation | full static dispatch and inlining; larger binaries and longer compiles |
Java's erasure was a compatibility decision: generic code had to interoperate with the pre-2004 collection classes and run on unchanged VMs. The price is visible daily.
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
a.getClass() == b.getClass(); // true — both are just ArrayList
// void f(List<String> x) {} and void f(List<Integer> x) {} — will not compile, same erasureThe bridge from erased generics back to real types is a type token — the pattern behind Class<T>, Jackson's TypeReference, and Gson's TypeToken, all of which exist to smuggle the type argument past erasure by capturing it in a subclass's signature.
Monomorphization is the opposite trade. Rust compiles Vec<u8> and Vec<String> into distinct machine code with no indirection, so generic code is as fast as hand-specialized code — and a heavily generic crate can multiply compile time and binary size. The escape hatch is dynamic dispatch (dyn Trait), which trades a vtable indirection for one shared body; see polymorphism and dispatch.
Variance #
Given Dog <: Animal, is List<Dog> <: List<Animal>? The answer follows from what you can do with the container, not from taste.
The unsoundness is immediate if you break the rule:
List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // NOT allowed in Java — and this is why
animals.add(new Cat()); // would compile
Dog d = dogs.get(0); // would be a CatJava arrays do allow it, which is why every reference-array store carries a runtime check and can throw ArrayStoreException (type systems). Generics deliberately did not repeat the mistake.
Three syntaxes, one rule #
Java — use-site variance (wildcards). The variance is declared by the consumer of the API, at each use:
void copy(List<? extends T> src, List<? super T> dst) // read from src, write to dstBloch's mnemonic PECS — Producer extends, Consumer super is exactly the intuition above. List<? extends Animal> can be read as Animal but nothing (except null) can be added to it, because the compiler does not know which subtype it actually holds.
Kotlin and C# — declaration-site variance. The variance is declared once, where the type is defined, and every use gets it automatically:
interface Source<out T> { fun next(): T } // covariant: T only in out-position
interface Sink<in T> { fun accept(item: T) } // contravariant: T only in in-positionThe compiler enforces the positions: putting T in a parameter of an out T interface is a compile error. This is the better ergonomics — the API author states the truth once instead of every caller restating it — at the cost of being unable to describe a type that is covariant at some uses and invariant at others. C# restricts it further: in/out are legal on interfaces and delegates only, never on classes.
Rust — variance is inferred, and it is about lifetimes. There is no subtyping between user types, so variance shows up almost entirely for lifetimes: &'long T coerces to &'short T (covariant in the lifetime), while &mut T is invariant in T for exactly the mutable-container reason above. Cell/RefCell and fn(T) arguments are the notable contravariant/invariant cases.
Function types are the case people get wrong #
A function is contravariant in its parameters and covariant in its return type. (Animal) -> Dog is a subtype of (Dog) -> Animal: it accepts everything the supertype accepts (and more), and returns something at least as specific.
Java got this wrong for overriding parameters historically — an override with a supertype parameter would be sound but is not permitted, and one with a subtype parameter is silently an overload rather than an override, which is what @Override exists to catch. TypeScript compares method parameters bivariantly by default, which is knowingly unsound and one of the reasons strictFunctionTypes exists.
Bounds are where the real design happens #
fn largest<T: PartialOrd + Copy>(items: &[T]) -> Tfun <T> sort(list: MutableList<T>) where T : Comparable<T>, T : SerializableA bound is a contract: it says exactly what the generic body is allowed to assume. Writing the loosest bound that compiles is a real design decision — Iterable<T> instead of List<T> in a parameter accepts strictly more callers at no cost to the implementation. The mirror rule applies to returns: be specific in what you return, general in what you accept.