Module 21 · Types and memory · Lesson 3 of 4
Generics and templates
Writing code once for many types without giving up type safety. Type parameters and bounds, three implementation strategies (erasure, reification, monomorphisation) and what each costs.
Why it matters
Before generics, a reusable container held plain objects and every read needed a cast that could fail at runtime. Generics move those checks to compile time: a list of orders can only hold orders, and reading from it needs no cast. They are everywhere, from collections to futures to repository interfaces, and two of their subtleties, how they are implemented and how they relate across subtypes, explain compiler errors and runtime surprises that otherwise look arbitrary.
The idea
A generic type or function has type parameters, placeholders filled in at each use: List<Order>, Map<UserId, Session>, Optional<T>. The code is written once against the placeholder; the compiler checks each use with the real type. This is parametric polymorphism: the behaviour is the same for every type, unlike subtype polymorphism where behaviour differs by type.
interface Repository<T, ID> {
Optional<T> findById(ID id);
void save(T entity);
}
<T extends Comparable<T>> T max(List<T> items) { // bounded: T must be comparable
T best = items.get(0);
for (T x : items) if (x.compareTo(best) > 0) best = x;
return best;
}
Bounds constrain the parameter so the generic code can call methods on it: T extends Comparable<T> in Java, where T : IComparable<T> in C#, trait bounds in Rust, constraints in Go.
Three ways to implement generics
| Strategy | How | Languages | Upside | Downside |
|---|---|---|---|---|
| Type erasure | Type parameters are checked at compile time, then removed; one compiled version works on references | Java, Kotlin (on the JVM), TypeScript | One copy of the code; backward compatible | No runtime type info: cannot write new T(), cannot tell List of String from List of Integer at runtime; primitives must be boxed |
| Reification | The runtime keeps type arguments and specialises for value types | C#, .NET | Runtime type checks work; no boxing for List of int | More runtime machinery |
| Monomorphisation | The compiler generates a separate copy for each concrete type used | C++ templates, Rust, Go (partly) | Fastest code, fully specialised and inlinable | Larger binaries, longer compiles, harder error messages (especially C++) |
Variance: when is a container of a subtype a subtype of the container?
A Dog is an Animal. Is a List<Dog> a List<Animal>? If it were, you could pass a list of dogs to a method expecting a list of animals, and that method could add a Cat to it, which would corrupt the caller's list of dogs. So for a mutable list the answer must be no: generic types are invariant by default.
Read-only and write-only uses are different. A producer you only read from can safely be covariant: a source of dogs is a source of animals. A consumer you only write to can safely be contravariant: something that accepts any animal also accepts dogs. Languages expose this as use-site wildcards (Java's ? extends T for reading, ? super T for writing) or declaration-site annotations (C#'s and Kotlin's out T and in T).
double totalWeight(List<? extends Animal> animals) { ... } // reads Animals: accepts List<Dog>
void addPuppies(List<? super Dog> sink) { sink.add(new Dog()); } // writes Dogs: accepts List<Animal>
Java arrays are covariant, a decision from before generics, which is why storing a Cat into an Animal[] that is really a Dog[] compiles and then throws ArrayStoreException at runtime. Generics were made invariant to move that error to compile time.
Trade-offs
Generics trade a little conceptual complexity for compile-time type safety and reuse. Erasure keeps one copy of the code but loses runtime type information and forces boxing; monomorphisation is fastest but grows binaries and compile times; reification sits between. Heavy generic abstraction (deeply nested type parameters, clever bounds) quickly becomes harder to read than the duplication it removed, so use generics for genuinely type-independent code like containers, results and repositories.
In an interview
Use them to express contracts precisely: "Repository of T and ID gives every aggregate the same find and save signatures with full type safety." If variance comes up, the dog-and-cat explanation of why mutable collections are invariant is the answer interviewers want.
Check yourself
In Java, a method accepts List<Animal>. Passing a List<Dog> fails to compile even though Dog extends Animal. Why is this the right behaviour?