Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Generics, Type Erasure and Wildcards

What the compiler removes and what it inserts, why you cannot create an array of a generic type, PECS explained by what it enables, bridge methods, and heap pollution from unchecked varargs.

Intermediate7 min readUpdated
On this page

Generics are compile-time only. Understanding exactly what the compiler removes, and exactly what it inserts in its place, explains every restriction the language imposes — and every restriction is a potential interview question.

Key Takeaways

  • Erasure replaces T with its leftmost bound (Object if unbounded) and inserts casts at every read site.
  • At runtime List<String> and List<Integer> are the same class. There is no type argument to inspect.
  • PECS: ? extends T to read, ? super T to write. You cannot do both through a wildcard.
  • You cannot instantiate new T(), new T[n], catch a generic exception, or overload on erasure- identical signatures.
  • Heap pollution is a List<String> that actually contains a non-String. Generic varargs are the usual route in.

What the compiler actually does

before erasure
public class Box<T extends Comparable<T>> {
    private T value;
    public T get()            { return value; }
    public void set(T value)  { this.value = value; }
}
 
Box<String> box = new Box<>();
box.set("hello");
String s = box.get();
after erasure — roughly what the bytecode holds
public class Box {
    private Comparable value;                 // T -> its leftmost bound
    public Comparable get()           { return value; }
    public void set(Comparable value) { this.value = value; }
}
 
Box box = new Box();
box.set("hello");
String s = (String) box.get();                // cast inserted by the compiler

Two moves: the type parameter is replaced by its bound, and a checked cast is inserted wherever a generic value is read. Generics are therefore not a runtime feature at all — they are a static guarantee plus automatic casts, which is exactly the code you would have written by hand in Java 1.4.

Everything that follows is a consequence.

The restrictions, and why each exists

You cannotBecause
new T()No class object for T at runtime
new T[10]The array would have no reifiable component type
o instanceof List<String>The type argument is gone; only the raw type survives
catch (MyException<T> e)The JVM matches exceptions by exact runtime class
void f(List<String>) and void f(List<Integer>)Both erase to f(List) — a duplicate method
A static field of type TStatics are per-class, and there is one class for all arguments

The array restriction deserves a moment, because it is the one that comes up in real code. Java arrays are covariant and reified — they know their component type and check it on every store:

why generic arrays are forbidden
Object[] objects = new String[1];   // legal: arrays are covariant
objects[0] = 42;                    // compiles, throws ArrayStoreException at runtime
 
// If this were legal:
List<String>[] lists = new List<String>[1];   // it is not
Object[] objs = lists;
objs[0] = List.of(1, 2, 3);         // no ArrayStoreException — type argument is erased
String s = lists[0].get(0);         // ClassCastException, far from the real mistake

The array's runtime check cannot see the type argument, so the error surfaces at an unrelated read instead of the bad write. Java forbids the creation outright rather than allow that. In practice you write List<List<String>>, or create (T[]) new Object[n] inside a class that keeps the array private — which is precisely what ArrayList does.

PECS

The rule is Producer Extends, Consumer Super, and it is best learned from what it makes possible.

Buffers.java
// Without wildcards, this only accepts List<Number> — not List<Integer>.
public static double sum(List<? extends Number> source) {   // PRODUCER: we read
    double total = 0;
    for (Number n : source) total += n.doubleValue();
    return total;
    // source.add(1) would not compile: the compiler knows the list is
    // *some* subtype of Number but not which, so no value is safe to add.
}
 
// Accepts List<Integer>, List<Number> or List<Object> — anything Integers fit into.
public static void fill(List<? super Integer> target, int count) {  // CONSUMER: we write
    for (int i = 0; i < count; i++) target.add(i);
    // Integer x = target.get(0) would not compile: reads come back as Object.
}

The asymmetry is the point. Through ? extends Number you can read a Number but write nothing; through ? super Integer you can write an Integer but read only Object. A parameter that must do both takes a plain T.

Collections.copy(List<? super T> dest, List<? extends T> src) is the canonical illustration — one parameter of each kind in one signature. The JDK is full of these; noticing the pattern in a signature you already use makes the rule stick.

Bridge methods

Erasure breaks polymorphism in one place, and the compiler patches it invisibly.

StringBox.java
class Box<T> {
    void set(T value) { }
}
 
class StringBox extends Box<String> {
    @Override void set(String value) { }   // erases to set(String)
}

Box.set erases to set(Object). StringBox.set is set(String). Different signatures, so the JVM would not treat one as overriding the other, and a call through a Box reference would dispatch to the wrong method. The compiler therefore generates a synthetic bridge method in StringBox:

generated, not written
void set(Object value) {           // bridge: matches the erased superclass signature
    set((String) value);           // casts and delegates — this is where a
}                                  // ClassCastException from a raw-type call originates

You see bridge methods in stack traces, in reflection output (Method.isBridge()), and as the source of a ClassCastException that appears to come from a line with no cast on it. That last case only happens when someone passes a Box raw reference holding a StringBox and calls set(42).

Heap pollution and unchecked varargs

Heap pollution is when a variable of parameterised type refers to an object that is not of that type. The compiler warns, then erasure lets it happen anyway.

the varargs hole
@SafeVarargs   // asserting this is safe — the author's responsibility
static <T> List<T> listOf(T... items) { return Arrays.asList(items); }
 
static <T> T[] toArray(T... args) { return args; }   // args is really Object[]
 
static <T> T[] pick(T a, T b) {
    return toArray(a, b);        // creates an Object[], not a T[]
}
 
String[] strings = pick("x", "y");   // ClassCastException at this line

A generic varargs parameter is compiled to an array of the erased type — Object[] here — so returning it as T[] is a lie the compiler cannot check. @SafeVarargs suppresses the warning and is an assertion by the author that the method never stores anything into the array and never lets it escape. Applying it to a method that does either is how the hole gets opened.

Raw types, and why they are still around

List without a type argument is a raw type: legal for compatibility with pre-Java-5 code, warned about by every compiler, and toxic in one specific way — using a raw type disables generic checking for the entire expression, not just the missing argument.

raw types disable more than you think
List raw = new ArrayList<String>();
raw.add(42);                        // unchecked warning only
 
List<String> safe = raw;            // unchecked warning only
String s = safe.get(0);             // ClassCastException — far from the actual mistake

If you genuinely need a list of unknown element type, use List<?>. It is type-safe: you can read elements as Object and the only thing you may add is null. That distinction — raw List versus List<?> — is a reliable interview question, and the answer is that one turns checking off and the other turns it up.

Frequently Asked Questions

Why does Java use erasure instead of reified generics?
Backward compatibility. Generics arrived in Java 5, a decade into the platform, and erasure let generic code and pre-generic code interoperate on the same JVM without a new bytecode format or a fork of the collections library. The cost is everything else on this page: no runtime type argument, no generic arrays, no instanceof on a parameterised type, and no overload distinguished only by type parameter.
What does PECS actually stand for and when do I use it?
Producer Extends, Consumer Super. If a parameter only produces values you read out of it, declare it as "? extends T". If it only consumes values you put into it, declare it as "? super T". If it does both, use plain T with no wildcard. The practical effect is that callers can pass a List of a subtype or a supertype where they otherwise could not.
Can I do instanceof List of String at runtime?
No. The type argument is erased, so at runtime a List of String and a List of Integer are the same class. You can only test the raw type — o instanceof List — and the compiler rejects the parameterised form. If you need the type at runtime you must pass a Class token explicitly, which is why so many APIs take a Class parameter alongside the generic one.

Related tutorials