Home
Java from First Principles / Chapter 11 — Generics and Type Erasure

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:

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

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

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

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

Java
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.**

Java
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.**

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

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

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

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

Java
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).

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

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

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

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

Java
List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs;   // compile error

Why is this rejected? Because if it were allowed:

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

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

Java
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.**

Java
public class Container<T> {
    public T create() {
        return new T();   // compile error
    }
}

Workaround: take a Supplier<T> or Class<T> parameter:

Java
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.**

Java
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.**

Java
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.**

Java
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.**

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

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

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