W3docs

Java Type Erasure

How Java implements generics through type erasure, what gets erased at runtime, and the consequences.

Java Type Erasure

Type erasure is how Java implements generics: the type parameters exist while the compiler is running, and then they're thrown away before the bytecode is written. A List<String> and a List<Integer> arrive at the JVM as plain List — interchangeable at runtime. The compile-time type system enforces safety; the runtime is the same List the language had in 1995. This design choice is the single most important fact about Java's generics, and it explains every weird restriction you'll meet in the next chapter.

Why Java did it this way

When generics were added in Java 5, the standard library was already a decade old. Every existing List, Map, and Comparator was non-generic, and every existing program in the world used them as raw types. Sun's hard requirement was binary backward compatibility: code compiled against the pre-5 standard library had to keep running on the new one without recompilation.

Two designs were on the table:

  1. Reified generics — keep the type information at runtime, the way C# eventually did. Faster, more expressive, but requires every existing class file in the world to be re-emitted.
  2. Erased generics — strip the type info at compile time, leave the bytecode shape unchanged. Slower per-call (extra casts), less expressive (no new T()), but every old JAR keeps working untouched.

Sun picked erasure. The pragmatic price for the upgrade was paid in long-term language flexibility. It's not the design anyone would pick from a blank slate — but it's the design Java has, and understanding it makes everything else about generics fall into place.

What erasure actually does

When the compiler sees a generic type, it does two things:

  1. Erases each type parameter to its leftmost bound — or to Object if there's no bound.
  2. Inserts casts at every place a generic value is read, so the runtime values land in the right slots.

Take this generic class:

public class Box<T extends Number> {
  private T value;

  public Box(T value)        { this.value = value; }
  public T   get()            { return value; }
  public void set(T value)    { this.value = value; }
}

After erasure, the bytecode looks roughly like this (in Java source equivalent):

public class Box {
  private Number value;

  public Box(Number value)            { this.value = value; }
  public Number get()                  { return value; }
  public void   set(Number value)      { this.value = value; }
}

The T is gone. It became Number because that was the bound. If there had been no bound, it would have become Object.

And at the call site:

Box<Integer> b = new Box<>(42);
int x = b.get();           // source

becomes (after erasure):

Box b = new Box(42);
int x = (Integer) b.get();   // compiler inserted the cast

The cast was invisible in the source. The compiler adds it because it knows the source-level type was Integer, even though the bytecode-level type is Number (or Object).

What that means at runtime

Several consequences fall directly out of erasure, and they're the source of every "but I thought I could…" moment a developer has with Java generics:

Box<Integer> a = new Box<>(1);
Box<Double>  b = new Box<>(1.0);

a.getClass() == b.getClass();   // true — both are Box.class

There's no Box<Integer>.class or Box<Double>.class at runtime — there's only Box.class. The two instances are the same class, because the JVM literally cannot tell them apart.

You can't ask "is this an instanceof Box<Integer>":

if (obj instanceof Box<Integer>) { ... }   // ❌ does not compile
if (obj instanceof Box<?>)        { ... }   // ✓ — wildcard is allowed
if (obj instanceof Box)           { ... }   // ✓ — raw form works

You can't write new T():

public class Factory<T> {
  public T create() { return new T(); }    // ❌ — no T at runtime
}

You can't catch a generic exception type:

try { ... }
catch (MyException<String> e) { ... }      // ❌

All of these compile errors trace back to the same fact: the JVM doesn't have the type parameter at the moment that code would need to execute. The compiler refuses to emit code it knows can't succeed.

Bridge methods — erasure's hidden bookkeeping

There's a subtlety where erasure interacts with overriding. Suppose you have:

interface Container<T> {
  void put(T value);
}

class IntContainer implements Container<Integer> {
  public void put(Integer value) { ... }
}

At the source level, IntContainer.put(Integer) overrides Container.put(T). But after erasure, the interface method has signature put(Object) — and IntContainer only has put(Integer). How does polymorphism still work when someone calls put through a Container reference?

The compiler generates a bridge method in IntContainer:

// Generated by the compiler, invisible in source:
public void put(Object value) {
  put((Integer) value);   // delegate to the real one
}

That bridge method is what gets called when polymorphic dispatch lands on the erased signature. You don't write it, you don't see it, but javap will show it to you. It's the glue that makes erasure work with virtual dispatch.

Erasure and overloading

A direct consequence of erasure: you cannot overload two methods if their signatures differ only in their generic parameters, because after erasure they have the same signature:

public void process(List<String> list)  { ... }
public void process(List<Integer> list) { ... }
// ❌ both erase to process(List) — compile error

This is the same lurking issue with overrides. If two methods would erase to the same signature, the compiler refuses to compile them. There's no workaround at the language level — you'd need different method names, or a parameter that's actually different post-erasure.

A worked example: erasure in action

The program demonstrates the things that fall out of erasure — runtime equality of getClass, a working raw-form instanceof, and the unchecked cast that the bytecode is doing for you on every read.

java— editable, runs on the server

The getClass() lines confirm the runtime can't tell Box<Integer> from Box<String> — there's only one Box class. The raw-form List trick is the textbook erasure story: the bad add(99) slips past the type check, and the failure surfaces at the next read because that's where the compiler-inserted cast lives. The exception message even tells you what the cast was: it tried to cast Integer to String.

What's next

Erasure isn't an academic detail — it's the reason behind almost every "you can't do that" the compiler will throw at you when you reach for clever generic code. The final chapter of this part catalogues the full list of those restrictions and explains each one in terms of what erasure prevents. Continue to Java Generics Restrictions.

Practice

Practice

Why does Java reject `public T first() { return new T(); }` inside a generic class `Foo<T>`?