JAVA / GENERICS AND TYPE ERASURE
Erasure and what the compiler removes
Predict what javac strips from generic code and what it inserts instead, so ClassCastExceptions and same-erasure errors stop surprising you.
What you will learn
- Predict any type parameter's erasure: Object if unbounded, else its leftmost bound
- Explain why List<String> and List<Integer> share a single runtime Class object
- Find the compiler-inserted cast that turns bad data into a ClassCastException
- Read erased and declared types apart with getType() and getGenericType()
Understanding Erasure and what the compiler removes
Generic types are checked by javac and then thrown away. After type checking, the compiler replaces every type parameter with its erasure (Object for an unbounded T, the leftmost bound for T extends Number) and drops type arguments from parameterized types, so List<String> becomes plain List in the bytecode. One class file therefore serves every parameterization: ArrayList<String> and ArrayList<Integer> are literally the same class at runtime, which is what allowed generics to be added in Java 5 without recompiling the libraries that came before them.
Removing types is only half of the transformation; the compiler also inserts what the untyped bytecode needs. Every place a T flows out of generic code gets a cast, so String s = names.get(0) compiles to a call to get returning Object followed by checkcast java/lang/String, and a generic method overridden with a narrowed parameter type gets a synthetic bridge method so virtual dispatch still reaches it. The mental model worth keeping is that your generic source is rewritten into pre-generics Java with hand-written casts, and those casts are the only runtime enforcement that exists, which is why a bogus element that got into a List<String> through a raw reference fails at the read rather than at the write.
What survives erasure is metadata about declarations, not about objects. The class file keeps a Signature attribute for fields, method signatures, type parameter bounds and supertypes, so reflection can report that a field is declared List<String> even though no List<String> instance can report its own element type. Every restriction erasure imposes follows from the same missing information: new T[n], T.class, x instanceof List<String>, catching a type variable, and two overloads that differ only in type argument all need a type argument at runtime, and there isn't one.
import java.util.ArrayList;
import java.util.List;
public class Erasure {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
List<Integer> ages = new ArrayList<>();
// One class file serves every parameterization.
System.out.println(names.getClass().getName());
System.out.println(names.getClass() == ages.getClass());
names.add("Ada");
List raw = names; // same object, type argument forgotten
raw.add(42); // compiles with a warning, no runtime check
Object smuggled = raw.get(1);
System.out.println(smuggled.getClass().getName());
System.out.println(names.size());
String first = names.get(0); // javac inserted (String) here
System.out.println(first.toUpperCase());
try {
String second = names.get(1); // and here, where it fails
System.out.println(second);
} catch (ClassCastException e) {
System.out.println("thrown at get, not at add: " + e.getClass().getSimpleName());
}
}
}
Generics are a compile-time contract: javac verifies type arguments, erases them to raw types and bounds, and inserts the casts and bridge methods the bytecode needs.
Worked examples
Erased type versus declared type
Reflection shows the same field as Number or Object after erasure while the class file still records the name T.
import java.lang.reflect.Field;
public class ErasedSignature {
static class Bounded<T extends Number> {
T value;
void set(T v) { value = v; }
}
static class Unbounded<T> {
T value;
}
public static void main(String[] args) throws Exception {
for (Class<?> c : new Class<?>[] { Bounded.class, Unbounded.class }) {
Field f = c.getDeclaredField("value");
System.out.println(c.getSimpleName() + " value: erased=" + f.getType().getName()
+ ", declared=" + f.getGenericType());
}
try {
Bounded.class.getDeclaredMethod("set", Integer.class);
} catch (NoSuchMethodException e) {
System.out.println("no set(Integer) exists; the only signature is set(Number)");
}
}
}
Example explained
Line 1f.getType() returns the erasure, so a T bounded by Number becomes Number and an unbounded T becomes Object.
Line 2f.getGenericType() reads the Signature attribute instead of the field descriptor, which is why the type variable name T is still available.
Line 3getDeclaredMethod("set", Integer.class) throws because the compiled parameter type is Number; reflection can only match erased signatures.
Line 4new Class<?>[] compiles because an unbounded wildcard needs no runtime type argument, while new Class<String>[] would be rejected.
The bridge method erasure forces javac to add
Implementing Comparator<String> produces two compare methods in the class file, one of them a synthetic bridge.
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Comparator;
public class BridgeMethod {
static class LengthOrder implements Comparator<String> {
public int compare(String a, String b) {
return a.length() - b.length();
}
}
public static void main(String[] args) {
Method[] ms = LengthOrder.class.getDeclaredMethods();
Arrays.sort(ms, Comparator.comparing((Method m) -> m.getParameterTypes()[0].getName()));
for (Method m : ms) {
System.out.println(m.getName() + "(" + m.getParameterTypes()[0].getSimpleName()
+ ", ...) bridge=" + m.isBridge() + " synthetic=" + m.isSynthetic());
}
Comparator<String> c = new LengthOrder();
System.out.println(c.compare("hi", "there"));
}
}
Example explained
Line 1The source declares one compare method, but Comparator<String> erases to compare(Object, Object), so javac emits a second method to satisfy the interface.
Line 2The generated method is flagged bridge and synthetic; its body casts both arguments to String and forwards to your compare.
Line 3c.compare("hi", "there") compiles to an invokeinterface on compare(Object, Object), so the call arrives through the bridge rather than directly.
Line 4The result is 2 - 5, printed as -3, showing the bridge changes dispatch but not behaviour.
Important notes
Erasure removes type arguments from objects, not from declarations: fields, method signatures, bounds and extends clauses keep their type arguments in the class file, which is how frameworks recover List<String> from a field they were handed.
The main example compiles with the note 'uses unchecked or unsafe operations'; that message means the compiler has stopped guaranteeing the element type, not that the line is dead on arrival.
Common mistakes
Overloading on type arguments, such as void save(List<String>) next to void save(List<Integer>): javac reports 'name clash: both methods have the same erasure' and no cast or annotation makes it legal, so the methods need different names.
Treating a ClassCastException on a get() line as a bug in that line; the wrong element entered the collection earlier through a raw or unchecked path, and the erased get is merely where the compiler's hidden cast finally checks it.
Writing new T[size] inside a generic class: javac rejects it with 'generic array creation' because the array would need a real element type in its header, so you must allocate Object[] and cast, or pass a Class<T> and use Array.newInstance.
Try it yourself
Change, predict, then run
In a browser editor, declare class Pair<A extends Comparable<A>, B> { A a; B b; } and print getType().getName() plus getGenericType() for both fields. Write down your prediction of all four values before running it, then explain any mismatch.
Open the Java workspaceCheck your understanding
Given static <T> T first(List<T> list) { return list.get(0); } and a caller String s = first(names); where does the cast to String end up in the compiled code?
- At the call site, immediately after first returns, because the erased method returns Object
- Inside first, applied to the value returned by list.get(0) before it is returned
- Inside ArrayList.get, which validates each element against the list's element type
- Nowhere, because the JVM remembers that names was created as a List<String>
Show answer
first erases to Object first(List), so the only code that knows the value is supposed to be a String is the caller, where javac emits checkcast java/lang/String. Option 1 is tempting, but inside first both list.get(0) and the declared return type erase to Object, so there is nothing to cast to; that single compiled body must serve every T. Option 3 fails because ArrayList stores an Object[] and performs no element type check.