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.

java
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 erasure

The 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:

java
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 Cat

Java 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:

java
void copy(List<? extends T> src, List<? super T> dst)   // read from src, write to dst

Bloch'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:

kotlin
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-position

The 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 #

rust
fn largest<T: PartialOrd + Copy>(items: &[T]) -> T
kotlin
fun <T> sort(list: MutableList<T>) where T : Comparable<T>, T : Serializable

A 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.

Sources

  1. docsThe Java Language Specification — Type Variables, Wildcards and Erasure
  2. docsKotlin docs — Generics: in, out, where (declaration-site variance)
  3. docsC# docs — Covariance and contravariance in generics
  4. docsRust Reference — Subtyping and variance
  5. bookEffective Java, 3rd Edition

Searches titles, aliases, headings, summaries, and full text.↑↓ move ↵ open esc close