Type Erasure in Java — What Disappears at Runtime and What That Means for Your Code
by Eric Hanson, Backend Developer at Clean Systems Consulting
What erasure means precisely
When you write List<String>, the compiler uses that type information to check assignments, method calls, and casts at compile time. After compilation, the bytecode contains List — the raw type. The String parameter is gone. The JVM at runtime has no knowledge that this particular list was ever parameterized with String.
This is erasure: the process of removing generic type parameters from compiled bytecode, replacing them with their bounds (or Object if unbounded), and inserting casts at the use sites.
The reason is backward compatibility. Generics were added in Java 5. The JVM predates them by a decade. Rather than change the runtime — which would have broken all existing bytecode — Sun introduced generics as a purely compile-time feature. The JVM runs the same bytecode it always did; the compiler does more work.
What disappears
Type arguments on instances. A List<String> and a List<Integer> are the same type at runtime — both are ArrayList (or whatever implementation). You cannot ask an instance what its type argument was:
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
System.out.println(strings.getClass() == integers.getClass()); // true — both ArrayList
strings instanceof List<String> // compile error before Java 16 pattern matching
Type arguments on method calls. When you call a generic method, the type argument is erased. The compiler inserts a cast at the call site:
// What you write
public <T> T firstElement(List<T> list) {
return list.get(0);
}
String s = firstElement(strings);
// What the compiler generates (conceptually)
public Object firstElement(List list) {
return list.get(0);
}
String s = (String) firstElement(strings); // cast inserted by compiler
The cast is invisible in source but present in bytecode. If the cast fails at runtime, you get a ClassCastException — the classic "heap pollution" scenario.
Generic array creation. You cannot create an array of a generic type because the array type check at runtime requires the element type, which has been erased:
T[] array = new T[10]; // compile error — generic array creation
List<String>[] lists = new ArrayList<String>[10]; // compile error — same reason
Arrays in Java are reified — they know their element type at runtime and enforce it on every store. Generics are erased. The two features are fundamentally incompatible, which is why generic arrays are illegal.
What survives
Not everything is erased. Some type information persists in class metadata and is accessible via reflection.
Declared types on fields, methods, and superclasses. If you declare a field as List<String>, the generic type signature survives in the class file's metadata — accessible via Field.getGenericType():
public class Container {
private List<String> items;
}
Field field = Container.class.getDeclaredField("items");
Type type = field.getGenericType(); // ParameterizedType
ParameterizedType pt = (ParameterizedType) type;
System.out.println(pt.getActualTypeArguments()[0]); // class java.lang.String
Method return types and parameter types. Similarly accessible via Method.getGenericReturnType() and Method.getGenericParameterTypes().
Supertype declarations. If a class explicitly extends a parameterized type, that information survives:
public class StringList extends ArrayList<String> {}
Type supertype = StringList.class.getGenericSuperclass();
ParameterizedType pt = (ParameterizedType) supertype;
System.out.println(pt.getActualTypeArguments()[0]); // class java.lang.String
This last point is the basis for the type token pattern.
The type token pattern — capturing type information
The most common workaround for erasure: use a Class<T> object as an explicit type token. Instead of relying on the runtime to know the type, you pass it explicitly:
public class TypedCache<T> {
private final Class<T> type;
private final Map<String, Object> store = new HashMap<>();
public TypedCache(Class<T> type) {
this.type = type;
}
public void put(String key, T value) {
store.put(key, value);
}
public T get(String key) {
return type.cast(store.get(key)); // safe cast using the token
}
}
TypedCache<String> cache = new TypedCache<>(String.class);
cache.put("greeting", "hello");
String value = cache.get("greeting"); // no unchecked cast warning
type.cast() is safer than a raw cast — it throws ClassCastException with a useful message and avoids unchecked warnings.
The limitation: Class<T> can only represent simple types. Class<List<String>> doesn't compile — List<String>.class is not valid Java. For generic types as tokens, you need ParameterizedType.
Super type tokens — capturing generic type information
The supertype-survives-erasure observation leads to a pattern popularized by Neal Gafter: the super type token.
public abstract class TypeToken<T> {
private final Type type;
protected TypeToken() {
Type superclass = getClass().getGenericSuperclass();
this.type = ((ParameterizedType) superclass).getActualTypeArguments()[0];
}
public Type getType() { return type; }
}
Usage — you create an anonymous subclass, which captures the type argument in the class file:
TypeToken<List<String>> token = new TypeToken<List<String>>() {};
System.out.println(token.getType()); // java.util.List<java.lang.String>
The anonymous subclass extends TypeToken<List<String>>. That supertype declaration survives erasure. The constructor reads it back.
This is how Jackson's TypeReference, Guava's TypeToken, and Spring's ParameterizedTypeReference work. When you use:
List<Order> orders = objectMapper.readValue(json, new TypeReference<List<Order>>() {});
The TypeReference<List<Order>> anonymous subclass carries the full type — List<Order> — in its class metadata. Jackson reads it at runtime to know what to deserialize into.
Heap pollution and unchecked warnings
Heap pollution occurs when a variable of a parameterized type refers to an object that isn't of that parameterized type. The compiler warns about this with "unchecked" warnings — it's telling you that erasure has removed its ability to guarantee type safety:
@SuppressWarnings("unchecked")
public static <T> List<T> dangerousCast(List<?> list) {
return (List<T>) list; // unchecked cast — compiler can't verify
}
List<String> strings = dangerousCast(List.of(1, 2, 3));
String s = strings.get(0); // ClassCastException here, not at the cast
The ClassCastException occurs at strings.get(0), not at the cast site — because the cast site does nothing at runtime (both are just List). The error surfaces where the element is used, not where the bad assumption was made. This makes heap pollution bugs genuinely difficult to trace.
The @SafeVarargs annotation exists for a specific heap pollution scenario — varargs with generic types — where the method can guarantee type safety despite the unchecked operation:
@SafeVarargs
public static <T> List<T> listOf(T... elements) {
return Arrays.asList(elements); // safe — we control how elements are used
}
@SafeVarargs suppresses the warning at the call site. Use it only when you've verified that the method doesn't expose the varargs array in an unsafe way.
Pattern matching and instanceof with generics
Java 16 introduced pattern matching for instanceof. It interacts with erasure in a predictable but sometimes surprising way:
Object obj = List.of("hello");
// This works — List is the raw type, which is reified
if (obj instanceof List<?> list) {
System.out.println(list.get(0));
}
// This doesn't compile — List<String> is not reified
if (obj instanceof List<String> list) { // compile error
}
List<String> as a pattern is illegal because the check would require type information that doesn't exist at runtime. List<?> is legal because the wildcard acknowledges the unknown element type.
Java 21's sealed classes and pattern matching in switch interact with erasure similarly — you can match on the raw type or on a wildcard-parameterized type, but not on a specific type argument.
The practical implications
Three design decisions where erasure determines the approach:
Generic deserialization. Whenever you deserialize into a generic type — JSON to List<Order>, a cache returning Map<String, Config> — you need a type token. Use the library's mechanism (TypeReference for Jackson, ParameterizedTypeReference for Spring's RestTemplate/WebClient) or implement super type tokens for your own generic deserialization.
Generic array alternatives. When you need array-like performance with a generic element type, use Object[] internally with casts, or use a List<T> instead. Many standard library classes — ArrayList, ArrayDeque — use Object[] internally for exactly this reason.
Unchecked cast containment. When an unchecked cast is genuinely necessary, isolate it in a single private method, document why it's safe, and suppress the warning there. Don't suppress warnings at the class level or in public methods where callers can't see the unsafe assumption.
Erasure is not a bug or an oversight — it was a deliberate backward-compatibility tradeoff. Understanding it precisely means you stop being surprised by ClassCastException at unexpected lines, understand why the libraries you use look the way they do, and make better decisions about where type information needs to be explicit rather than inferred.