What is type erasure, and what does it prevent?

Asked constantlyintermediate2–10 yrs11 min readJava 5 (1.5)Java 7Java 9

Generics are checked by the compiler and then mostly discarded, so at runtime a List<String> and a List<Integer> are the same class. That is why you cannot write new T[] or instanceof List<String>. But erasure is not total — type arguments survive in the class file for fields, method signatures and supertypes, which is the entire reason Jackson can deserialise a List<Order>.

The Answer

  • Generics are a compile-time feature. javac checks your types, inserts the casts you did not write, and then erases the type arguments.
  • After erasure, List<String> and List<Integer> are both just List. getClass() proves it — they return the same Class object.
  • An unbounded T erases to Object; a bounded <T extends Number> erases to Number.
  • So anything needing the type argument at runtime is banned: new T[], o instanceof List<String>, catch (MyException<T> e), and two overloads whose parameters differ only inside the angle brackets.
  • Erasure was chosen for migration compatibility — Java 5 generic code had to keep calling Java 1.4 libraries, and vice versa, without recompiling the world.
  • The caveat most people miss: erasure applies to instances, not to declarations. A field declared List<String> keeps List<String> in the class file, which is how every JSON library on earth knows what to build.

Understand It

What actually disappears

Run this and the whole rule falls out of three lines:

Compiled and run on this buildEdit and run
List<String> names = new ArrayList<>();
List<Integer> scores = new ArrayList<>();

System.out.println("names.getClass()  : " + names.getClass().getName());
System.out.println("scores.getClass() : " + scores.getClass().getName());
System.out.println("same Class object : " + (names.getClass() == scores.getClass()));
Output
names.getClass()  : java.util.ArrayList
scores.getClass() : java.util.ArrayList
same Class object : true

There is one ArrayList class, and it holds Object references. List<String> is a promise javac makes to you at compile time and keeps by inserting a checkcast every time you read an element. It is not a runtime type.

That single fact explains the entire list of things generics cannot do. new T[10] cannot work because at runtime there is no T to hand to the array — arrays do carry their component type, which is why String[] throws ArrayStoreException and List<String> cannot. o instanceof List<String> cannot work because the object has no memory of ever having been a List<String>.

Why Java did it this way

C# added generics two years later and reified them — the runtime knows a List<string> from a List<int>, and gets a real performance win from not boxing. Java could not, and the reason is not technical timidity.

Java 5 shipped into an ecosystem with an enormous quantity of compiled, unrecompilable code that passed raw Collections around. The design goal was migration compatibility: ArrayList<String> had to be the same class as the ArrayList in a 1999 JAR, so that new code could pass a List<String> to an old library and the old library could hand one back. Erasure is what buys that. The price is the list of restrictions below, and the ecosystem paid it deliberately.

Erasure is not total, and this is the part that earns you the job

"Generic information is gone at runtime" is repeated everywhere, and it is wrong as stated. What is erased is the type argument of an object. What javac keeps, in a class-file attribute called Signature, is the generic type of every field, method parameter, return type and supertype:

Compiled and run on this buildEdit and run
class Registration {
    List<String> attendees = new ArrayList<>();
    Map<String, List<Integer>> scoresByTeam = new HashMap<>();
}

fieldTypes(Registration.class);
Output
attendees
  erased  : java.util.List
  generic : java.util.List<java.lang.String>
scoresByTeam
  erased  : java.util.Map
  generic : java.util.Map<java.lang.String, java.util.List<java.lang.Integer>>

getType() is the erased view. getGenericType() reads the Signature attribute and gets everything back, nesting included.

The same applies to supertypes, and that is the trick behind TypeToken in Guava, TypeReference in Jackson and ParameterizedTypeReference in Spring. A variable of type List<String> tells you nothing at runtime. A class that extends ArrayList<String> tells you everything — so those libraries make you create one, with an empty pair of braces:

Compiled and run on this buildEdit and run
List<String> plain = new ArrayList<>();
List<String> token = new ArrayList<String>() {};   // note the {} — an anonymous subclass

System.out.println("plain superclass : " + plain.getClass().getGenericSuperclass());
System.out.println("token superclass : " + token.getClass().getGenericSuperclass());
Output
plain superclass : java.util.AbstractList<E>
token superclass : java.util.ArrayList<java.lang.String>

Look closely at the first line. plain.getClass() is ArrayList, and asking ArrayList about its superclass gets you AbstractList<E> — the type variable, unresolved, because ArrayList was compiled once for everybody. The declaration List<String> plain is a fact about the variable, and the variable is not reachable from the object.

The second line is the whole trick. Those braces are not decoration: they create a new class, and that class's Signature attribute records ArrayList<String> because that is what the anonymous subclass literally extends. The library reads it back with one getGenericSuperclass() call. Next time you write new TypeReference<List<Order>>() {} and wonder why the empty block is mandatory, that is the answer — drop the braces and you hand the library a variable instead of a class.

Bridge methods, or why your class has a method you never wrote

Erasure creates a problem for overriding. Comparable<T> erases to a compareTo(Object) method, but you wrote compareTo(Person). Those are different signatures, so the JVM would not treat yours as an override — and every call through the interface would miss it.

javac fixes this by generating a bridge method: a synthetic compareTo(Object) that casts and delegates to yours.

Compiled and run on this buildEdit and run
class Person implements Comparable<Person> {
    final String name;
    Person(String name) { this.name = name; }
    @Audited(reviewer = "anita")
    public int compareTo(Person other) { return name.compareTo(other.name); }
    public String toString() { return name; }
}

overloadsOf(Person.class, "compareTo");

List<Person> people = new ArrayList<>(List.of(new Person("Ravi"), new Person("Anita")));
Collections.sort(people);
System.out.println("sorted                        : " + people);
Output
compareTo(Object)  bridge=true   synthetic=true   @Audited=true
compareTo(Person)  bridge=false  synthetic=false  @Audited=true
sorted                        : [Anita, Ravi]

You wrote one method; the class file has two. Collections.sort calls the bridge, the bridge casts to Person and calls yours. This matters for anything that walks methods reflectively — Spring AOP, a mapping framework, your own startup check. getDeclaredMethods() returns both, so a scanner that does not filter will process your method twice.

And look at the last column, because this is where the widely-repeated advice is out of date. Plenty of write-ups tell you to spot a bridge by its missing annotations. On a current javac it inherits them: @Audited is on both rows. The only reliable test is Method.isBridge(), and the only reliable habit is to call it before you trust a reflective method list.

Heap pollution: the cast that fires somewhere else

Because the type argument is not checked at runtime, a raw reference can smuggle the wrong thing into a collection. The ClassCastException then happens at the read, in code that is perfectly correct:

Compiled and run on this buildEdit and run
List<Integer> scores = new ArrayList<>();
List raw = scores;                  // raw type: checking switched off
raw.add("not a number");

System.out.println("size after adding a String : " + scores.size());
try {
    int first = scores.get(0);
    System.out.println(first);
} catch (ClassCastException e) {
    System.out.println("ClassCastException at the read");
    System.out.println("  " + e.getMessage());
}
Output
size after adding a String : 1
ClassCastException at the read
  class java.lang.String cannot be cast to class java.lang.Integer (java.lang.String and java.lang.Integer are in module java.base of loader 'bootstrap')

That is what the term heap pollution means, and it is why an unchecked warning is worth fixing rather than suppressing. The stack trace points at the innocent line. The guilty line is the one that used a raw type, possibly in a different class, possibly months earlier.

Reference

What erasure turns each declaration into

You writeThe class file sees
class Box<T>class Box, with T erased to Object
class Box<T extends Number>T erased to Number
class Box<T extends Comparable<T> & Serializable>T erased to Comparablethe leftmost bound
List<String> f()returns List, with Signature recording List<String>
void f(List<String>)void f(List)

The leftmost-bound rule is the one that surprises people: reorder the bounds and the erased signature changes, which is a binary-incompatible change to a public API.

The five things you cannot do, and what to write instead

// 1. No generic array creation.
T[] bad = new T[n];                                  // error: generic array creation
T[] ok  = (T[]) new Object[n];                       // the standard workaround; keep it private
E[] best = (E[]) Array.newInstance(componentType, n); // when a Class<E> is available

// 2. No instanceof against a type argument.
if (o instanceof List<String>) { }                   // error: cannot be safely cast
if (o instanceof List<?> list) { }                   // legal — checks the erasure only

// 3. No overloads that differ only in type arguments.
void f(List<String> a) { }
void f(List<Integer> b) { }                          // error: same erasure

// 4. No generic exception types.
class MyException<T> extends Exception { }
// error: a generic class may not extend java.lang.Throwable

// 5. No static field of the class's own type parameter.
class Box<T> { static T shared; }
// error: non-static type variable T cannot be referenced from a static context

The real javac messages, so you recognise them:

error: name clash: f(List<Integer>) and f(List<String>) have the same erasure
error: generic array creation
error: Object cannot be safely cast to List<String>
error: Comparable cannot be inherited with different arguments: <java.lang.String> and <java.lang.Integer>

Recovering the type when you actually need it

// Pattern 1 — pass the Class in. Bulletproof, and the JDK's own answer
// (Collections.checkedList, EnumMap, the whole of java.lang.reflect).
class Repository<T> {
    private final Class<T> type;
    Repository(Class<T> type) { this.type = type; }
    T parse(String json) { return mapper.readValue(json, type); }
}
new Repository<>(Order.class);

// Pattern 2 — the super type token. Needed when the type is itself generic,
// because there is no Class object for List<Order>.
new TypeReference<List<Order>>() { }                  // Jackson
new ParameterizedTypeReference<List<Order>>() { }     // Spring RestClient / WebClient
new TypeToken<List<Order>>() { }                      // Guava / Gson

// Pattern 3 — read it off a field or method you control.
Type t = Registration.class.getDeclaredField("attendees").getGenericType();
ParameterizedType p = (ParameterizedType) t;
Type arg = p.getActualTypeArguments()[0];             // java.lang.String

Varargs and @SafeVarargs

// Every generic varargs method creates an array of a non-reifiable type, so
// javac warns at the DECLARATION and at every CALL SITE.
static <T> List<T> listOf(T... items) { return List.of(items); }

// @SafeVarargs silences both — and is a promise you are making: this method
// never writes to the array and never lets it escape. Legal on static, final
// and private methods, plus constructors.
@SafeVarargs
static <T> List<T> listOf(T... items) { return List.of(items); }

Scenarios

Your REST client returns LinkedHashMap instead of your DTO. A RestTemplate or WebClient call typed List<OrderDto> comes back full of maps, and the ClassCastException lands in the loop that reads it. Nothing is broken in the mapper: the generic type never reached it, so it built the only thing it could. Pass a ParameterizedTypeReference<List<OrderDto>>() {} — an anonymous subclass, braces included — and the Signature attribute carries the type through. This is erasure's single most common real-world appearance, and it is why the API is shaped so awkwardly.

A compliance check counts every comparator twice. You write a startup scan that walks getDeclaredMethods() looking for an annotation, and the report duplicates exactly the classes that implement a generic interface. You are seeing bridge methods. The trap is that the obvious debugging move fails: the bridge carries the annotation too, so "only count the annotated ones" does not deduplicate anything. Filter on m.isBridge(). Reaching for isSynthetic() instead is broader — it also hides lambda bodies and inner-class accessors — so pick one deliberately and write down which, because reflection will not make that choice for you.

Someone proposes an @SuppressWarnings("unchecked") on the class. The build is noisy, the deadline is real, and the annotation makes it quiet. Push back, but not on principle — on blast radius. At class level it silences every future unchecked operation anyone adds to that file, including the one that will actually pollute the heap next year. If the cast is genuinely safe, suppress it on the single statement with a comment saying why it is safe. That comment is the code review. Two lines, and the warning keeps working for everyone else.

You want reified generics and someone mentions Valhalla. It comes up, and the honest answer is that erasure is not going away as a compatibility model. Project Valhalla's specialised generics target value types — List<int> without boxing — not runtime type arguments for reference types. Writing code today that assumes a future where new T[] compiles is planning on a release nobody has scheduled. Design around the type token instead.

Interviewer's Next Move

1. "Why can't you write new T[10]?" Because at runtime there is no T. Arrays are reified — every array object carries its component type and checks stores against it — so the JVM would have nothing to write into the array header. The workaround is (T[]) new Object[10] kept private to the class, which is exactly what ArrayList does internally with its elementData field.

2. "What does <T extends Comparable<T> & Serializable> erase to?" To Comparable — the leftmost bound. That matters beyond trivia: swapping the two bounds changes the erased signature of every method using T, so it is a binary-incompatible change even though the source looks equivalent.

3. "You said the type is erased. So how does Jackson build a List<Order>?" Because erasure applies to instances, not declarations. The generic type of a field, a method parameter, a return type and a supertype is preserved in the class file's Signature attribute, and getGenericType() / getGenericSuperclass() read it. That is why TypeReference is an anonymous subclass with {} — you are manufacturing a declaration for the library to read.

4. "What is a bridge method, and when would it bite you?" A synthetic method javac generates so that an override with a specific parameter type still overrides the erased signature from the interface. It bites reflective code: getDeclaredMethods() returns it, so a scanner that ignores isBridge() processes your method twice. And you cannot detect it by a missing annotation — current javac copies your annotations onto the bridge, which is the opposite of what most articles claim.

5. "Two methods, process(List<String>) and process(List<Integer>). Why won't it compile, and how would you fix it?" Both erase to process(List), so it is a name clash, not an overload. Fix it by renaming — processNames and processScores — which is also better API design, or by taking a single List<?> plus a discriminator. Adding a dummy parameter to force different erasures works and should not survive review.

Code traps

List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.equals(b));
System.out.println(a.getClass() == b.getClass());
Answer

true and true. Two empty lists are equal by AbstractList.equals — element-wise, and there are no elements — and they are the same class, because the type argument is gone. Neither line has anything to do with String or Integer.

static <T> void addAll(List<T> list, T... items) {
    Object[] array = items;
    array[0] = "oops";
    System.out.println(items[0]);
}
// called as addAll(new ArrayList<Integer>(), 1, 2, 3)
Answer

ArrayStoreException. The varargs array is created at the call site with the erasure of T, which here is Integer, so it is a real Integer[]. Assigning it to Object[] is legal — arrays are covariant — but the store is checked at runtime and fails. This is precisely why generic varargs produce a warning and why @SafeVarargs is a promise not to do this.

Common wrong answers

Said in interviewsReality
"Generic information is completely gone at runtime."Only for instances. Fields, parameters, return types and supertypes keep it in the Signature attribute.
"Erasure exists for performance."It exists for migration compatibility with pre-generics bytecode. Reification would be faster, not slower.
"List<String> and List<Integer> are different classes."Same class object. == on the two getClass() results returns true.
"The unchecked warning is just noise."It marks the exact place heap pollution can enter. The exception surfaces somewhere else entirely.
"You can spot a bridge method because it has no annotations."Current javac copies them onto the bridge. isBridge() is the only reliable test.
"You can catch MyException<T>."Generic types cannot extend Throwable at all — the compiler rejects the class, not just the catch.

Check Yourself

Q1. Why is o instanceof List<String> a compile error while o instanceof List<?> is fine?

Answerinstanceof is a runtime check, and at runtime there is nothing to distinguish a List<String> from any other list. List<?> asks only about the erasure, which the object does carry, so it is answerable.

Q2. You reflectively list the methods of a class implementing Comparator<Order> and find two compare methods. Which is yours, and how do you tell?

AnswerYours is the one whose parameter is Order; the Object-parameter copy is the bridge javac generated so the erased interface method is really overridden. Method.isBridge() returns true on it. Do not try to tell them apart by annotations — modern javac copies yours onto the bridge, so both appear annotated.

Q3. A JSON library returns LinkedHashMap objects where you declared List<Customer>. What single change fixes it, and why does that change work?

AnswerGive the library a super type token — new TypeReference<List<Customer>>() {} — instead of a plain Class. The empty braces create an anonymous subclass whose Signature attribute records List<Customer>, and the library recovers it with getGenericSuperclass(). Passing List.class cannot work: there is no Class object for List<Customer>.


Practice

What changed, and when

Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.

  1. Java 5 (1.5)

    Generics arrive, implemented by erasure so that generic and pre-generic code can call each other freely.

    Before Java 5 (1.5): Collections held Object. Every read was a manual cast, and a wrong one blew up at the cast site with no compile-time warning at all.

  2. Java 7

    The diamond operator infers the type arguments on the right-hand side, so new HashMap<>() replaces the repeated declaration.

    Before Java 7: You wrote new HashMap<String, List<Integer>>() in full on both sides of the assignment.

  3. Java 9

    The diamond works on anonymous classes too — but only when the inferred type is denotable, so the new ArrayList<String>(){} trick for capturing a type still has to spell the argument out.

    Before Java 9: new ArrayList<>(){} was a compile error; anonymous classes always needed explicit type arguments.

Practice ladder

Reading this page is not knowing it. Four tiers, ending in a real incident.

Where this question goes next

Questions that lead here

  • What do sealed classes enable that abstract classes cannot?

    An abstract class says 'anyone may extend me'. A sealed one names its subtypes, so the compiler knows the list is complete — and can prove a switch covers every case. That turns adding a subtype into a compile error at every place that has to handle it, which is the opposite of the silent default branch you get today.

    Asked oftenintermediate2–12 yrs11 min readModern java

Every runnable example above was compiled and executed against openjdk 21.0.12 on this build, and its output diffed against what this page claims. Last updated 2026-09-13.