Generics and Type Erasure
How generics actually work — at compile time, anyway. Type erasure, bounded types, wildcards, and the weird things they enable and forbid.
The problem generics solve
Before Java 5 (released 2004), collections held raw Object references:
List list = new ArrayList();
list.add("hello");
list.add(42); // anything could go in
String s = (String) list.get(0); // explicit cast, runtime risk
String s2 = (String) list.get(1); // ClassCastException at runtime
The compiler couldn't tell you what kind of things a List held. Every retrieval needed a cast. Putting the wrong type in succeeded silently; pulling it out blew up later with ClassCastException. That's the worst kind of bug — distant from the cause.
Java 5 added generics. Now collections carry their element type:
List<String> list = new ArrayList<>();
list.add("hello");
list.add(42); // compile error — int isn't a String
String s = list.get(0); // no cast needed
The compiler enforces the type, catches mismatches before the program runs, and removes the need for casts. It's the single biggest improvement to Java's type system since version 1.0.
But generics in Java aren't quite like generics in C# or templates in C++. They have a peculiar limitation: **type erasure**. Understanding what that means — and what it lets you and doesn't let you do — is the deep end of this chapter.
Generic classes
Declaring your own generic class is straightforward. The type parameter goes in angle brackets after the class name:
public class Box<T> {
private T value;
public Box(T value) {
this.value = value;
}
public T get() {
return value;
}
public void set(T value) {
this.value = value;
}
}
Box<String> sb = new Box<>("hello");
String s = sb.get(); // type known: String
Box<Integer> ib = new Box<>(42);
Integer n = ib.get();
The type parameter T is a placeholder. When you write Box<String>, Java treats every T inside the class as String from the compiler's perspective.
**Conventional names** for type parameters:
- T — Type (single, generic)
- E — Element (used in collections)
- K, V — Key, Value (in Map)
- R — Return type
- N — Number
These are conventions, not requirements. Box<Banana> works too, but Box<T> is the more readable convention.
**Multiple type parameters:**
public class Pair<K, V> {
private K key;
private V value;
// ...
}
Pair<String, Integer> p = new Pair<>("age", 30);
**Generic methods.** A method can have its own type parameters, independent of its enclosing class:
public class Util {
public static <T> List<T> singletonList(T item) {
return List.of(item);
}
}
List<String> ss = Util.singletonList("hello");
List<Integer> is = Util.singletonList(42);
The <T> before the return type declares it. Java infers the actual type from the argument — you almost never write Util.<String>singletonList("hello") explicitly.
Type erasure — the catch
Here's where Java generics differ from most other languages. **Generic type information is erased at compile time.** At runtime, List<String> and List<Integer> are both just List.
The compiler uses the type parameter to:
1. Check your code is type-correct at compile time.
2. Insert casts where needed (so list.get(0) returns String automatically without you writing (String)).
But the compiled bytecode contains no record of "this was a List of Strings". The JVM only sees List.
What this means in practice:
**You can't check generic types at runtime.**
List<String> ss = new ArrayList<>();
if (ss instanceof List<String>) { ... } // compile error
if (ss instanceof List<?>) { ... } // fine — wildcard works
**You can't create arrays of generic types.**
T[] arr = new T[10]; // compile error
List<String>[] arr = new List<String>[10]; // compile error
List<?>[] arr = new List<?>[10]; // works, but element type unchecked
**You can't use a type parameter in a static field or static method directly:**
public class Box<T> {
private static T defaultValue; // compile error — T isn't bound at class level
}
**Two methods that differ only in their generic type parameters cannot coexist:**
public void process(List<String> list) { ... }
public void process(List<Integer> list) { ... } // compile error — both erase to process(List)
At runtime, both methods would have the same signature process(List). The JVM can't distinguish them, so the compiler refuses to let you write both.
**Why did Java choose erasure?** Backward compatibility. When generics were added in Java 5, every existing .class file was non-generic. Erasure meant generic and non-generic code could interoperate seamlessly — your new List<String> could be passed to a pre-5 library that expected List, and vice versa. The price was permanent runtime erasure of type info.
Bounded type parameters
Sometimes you want a generic type to satisfy some constraint. Use extends:
public class NumberBox<T extends Number> {
private T value;
public NumberBox(T value) { this.value = value; }
public double asDouble() {
return value.doubleValue(); // Number has doubleValue() — safe to call
}
}
NumberBox<Integer> ib = new NumberBox<>(42); // Integer extends Number — OK
NumberBox<Double> db = new NumberBox<>(3.14); // Double extends Number — OK
NumberBox<String> sb = new NumberBox<>("hi"); // compile error
T extends Number means T must be Number or a subclass. Now inside NumberBox, you can call methods declared on Number (doubleValue(), intValue(), etc.).
**Bounded type parameters work with interfaces too.** <T extends Comparable<T>> says T must implement Comparable for ordering itself.
**Multiple bounds** are written with &:
public <T extends Number & Comparable<T>> T max(T a, T b) {
return a.compareTo(b) > 0 ? a : b;
}
T must be BOTH a Number AND Comparable. Use sparingly — multiple bounds get awkward fast.
Wildcards: `?`, `? extends`, `? super`
Wildcards apply at the use site, not the declaration site. They let you write methods that accept a range of generic types.
**The unbounded wildcard <?>** — "some unknown type". You can READ from it as Object, but you can't add to it (except null).
public void printAll(List<?> list) {
for (Object o : list) {
System.out.println(o);
}
}
printAll(new ArrayList<String>()); // OK
printAll(new ArrayList<Integer>()); // OK
**<? extends T>** — "T or some subclass". Useful for READING values.
public double sumOfNumbers(List<? extends Number> list) {
double total = 0;
for (Number n : list) { // safe — every element is at least Number
total += n.doubleValue();
}
return total;
}
sumOfNumbers(new ArrayList<Integer>()); // OK — Integer extends Number
sumOfNumbers(new ArrayList<Double>()); // OK
You can't ADD to a List<? extends Number> (except null), because the compiler doesn't know which specific subtype it actually is. Could be List<Integer> — adding a Double would be wrong.
**<? super T>** — "T or some superclass". Useful for WRITING values.
public void addAll(List<? super Integer> list, int n) {
for (int i = 0; i < n; i++) {
list.add(i); // safe — list accepts Integer or its supertypes
}
}
addAll(new ArrayList<Integer>(), 10); // OK
addAll(new ArrayList<Number>(), 10); // OK — Number is super of Integer
addAll(new ArrayList<Object>(), 10); // OK
You can ADD Integers (or subtypes), but READING gives you only Object (the lowest common supertype).
**The PECS mnemonic** (from Effective Java): **P**roducer **E**xtends, **C**onsumer **S**uper.
- If the parameter is producing values FOR you to read → extends.
- If the parameter is consuming values FROM you to write → super.
This is exactly what Collections.copy does:
public static <T> void copy(List<? super T> dest, List<? extends T> src) { ... }
src produces; dest consumes. Read with extends, write with super.
Generics and inheritance — the trap
A Dog is an Animal. So is a List<Dog> also a List<Animal>? **No.** This is called the **non-covariance** of generics, and it catches everyone at least once.
List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // compile error
Why is this rejected? Because if it were allowed:
List<Animal> animals = dogs;
animals.add(new Cat()); // legal — Cat is an Animal
Dog d = dogs.get(0); // but dogs[0] is now a Cat — ClassCastException!
To preserve type safety, Java forbids treating List<Dog> as List<Animal> directly. But you CAN do it with a wildcard:
List<? extends Animal> someAnimals = dogs; // OK — read-only Animal view
Animal a = someAnimals.get(0); // works
someAnimals.add(new Cat()); // compile error
List<? extends Animal> says "some specific list of Animal subtypes — I don't care which". You can read Animals from it, but can't add anything to it (because you'd break the underlying list).
The same principle applies to all parameterized types. Box<Dog> is NOT a Box<Animal>. Map<String, Dog> is NOT a Map<String, Animal>.
**Arrays, by contrast, ARE covariant** — historic Java design that pre-dates generics:
Animal[] animals = new Dog[5]; // legal (and dangerous)
animals[0] = new Cat(); // compiles, but throws ArrayStoreException at runtime
Array covariance is a known mistake. Generics deliberately reject it. Prefer List<T> over T[] in any new code.
Things you can't do with generics
The full list of restrictions from type erasure, with workarounds where they exist.
**1. Can't instantiate a type parameter.**
public class Container<T> {
public T create() {
return new T(); // compile error
}
}
Workaround: take a Supplier<T> or Class<T> parameter:
public class Container<T> {
private final Supplier<T> factory;
public Container(Supplier<T> factory) { this.factory = factory; }
public T create() { return factory.get(); }
}
new Container<>(ArrayList::new);
**2. Can't create arrays of parameterized types.**
List<String>[] arr = new List<String>[10]; // compile error
Workaround: use List<List<String>> instead of an array.
**3. Can't check instanceof of parameterized type.**
if (obj instanceof List<String>) { ... } // compile error
Workaround: check the raw type (obj instanceof List<?>), then verify element types as needed.
**4. Can't catch a parameterized exception type.**
public class MyException<T> extends Exception { ... } // legal to define
try { ... } catch (MyException<String> e) { ... } // illegal
The runtime can't distinguish MyException<String> from MyException<Integer>, so catch by parameterized type is forbidden.
**5. Can't overload methods that erase to the same signature.**
public void process(List<String> list) { ... }
public void process(List<Integer> list) { ... } // compile error
Workaround: rename one (processStrings, processNumbers).
Practical guidance
The patterns that hold up in real codebases.
**Use generics for collections and containers.** Almost every class that holds "some objects" should be generic. List<T>, Map<K, V>, Optional<T>, Stream<T>, Set<T>, Future<T>, Comparator<T> — all generic by design.
**Use bounded type parameters when you need methods on T.** <T extends Comparable<T>> enables compareTo(). <T extends Number> enables numeric methods. Without bounds, T is only Object — you can call toString() and not much else.
**Use wildcards in method signatures, not type parameters in field declarations.** This is the most subtle rule. Inside a class, fields are typed with the class's type parameter. In method signatures that take collections from callers, prefer wildcards:
public class Library<B extends Book> {
private List<B> books; // field uses the class's T
public void addAll(Collection<? extends B> source) { // method uses wildcard
books.addAll(source);
}
}
Now addAll accepts any collection whose elements are Books — not just Collection<B> exactly. More flexible for callers.
**Don't fight type erasure with reflection unless you have no choice.** It's possible to recover SOME generic type info via reflection (Class.getGenericInterfaces, etc.), but it's brittle and usually a code smell. Frameworks like Spring use it for dependency injection; application code rarely should.
**For modern Java code, prefer Records and sealed types over generic wrapper classes for data:**
// Instead of:
public class Result<T> {
private final T value;
private final String error;
}
// Consider:
public sealed interface Result<T> {
record Success<T>(T value) implements Result<T> {}
record Failure<T>(String error) implements Result<T> {}
}
We'll cover sealed types in the modern Java chapter.
⁂ Back to all modules