Type Erasure
Understand why type arguments vanish at runtime, which code that rules out, and the Class<T>, Supplier<T> and type-token workarounds.
List<String> a; List<Integer> b; // same runtime class: ArrayList
<T> T read(String json, Class<T> type)
<T> T create(Supplier<T> factory)
new TypeRef<List<User>>() {} // captures the full typeGenerics are checked by the compiler and then erased. In the compiled class file, a type parameter is replaced by its bound, or by Object when it has none, and the compiler inserts casts where values come out. At runtime a List<String> and a List<Integer> are both plain ArrayList objects of the same class. This kept code written before generics (Java 5) working with code written after.
What erasure rules out
new T()andnew T[n]: the runtime does not know whatTis.obj instanceof List<String>whenobjis anObject: the check cannot be made. Testinstanceof List<?>and check the elements.T.classand catching a type parameter as an exception type.- Overloads that differ only by type argument.
void save(List<User> u)andvoid save(List<Order> o)have the same erasure and do not compile together; give them different names. - Primitive type arguments, since
Objectcannot hold anint.
Heap pollution
Raw types and unchecked casts can put the wrong type into a generic collection. Nothing fails at the bad write, because the list is just an ArrayList of Object. The ClassCastException comes later, at the cast the compiler inserted where the value is read, which may be in a different class or a different request. That is why unchecked warnings deserve attention: they mark the place where the compiler could not keep its promise.
The workarounds
- Pass a
Class<T>token.type.isInstance(o)andtype.cast(o)do the check erasure removed. JSON and database libraries use this:readValue(json, User.class). - Pass a
Supplier<T>instead of callingnew T(), for exampleArrayList::neworStringBuilder::new. - Capture the type in a subclass. Erasure removes type arguments from objects, but class and method declarations keep their generic signatures. An anonymous subclass of
TypeRef<List<User>>recordsList<User>in its declaration, and reflection can read it back. Jackson'sTypeReferenceand Spring'sParameterizedTypeReferenceuse this trick to deserialise aList<User>, whichList.classalone cannot describe.
Why it matters in a service
Most of the time erasure is invisible. It shows up when deserialising JSON into a generic type, when a framework needs to know the entity type of a repository, and when legacy code with raw types feeds your typed code. Knowing that the type is gone at runtime explains why those APIs ask you for a Class or a type reference.
Example
When to use it
- Pass User.class or a type reference to a JSON library so a request body is deserialised into the right type.
- Filter a mixed list of domain events down to one event type with a Class<T> token.
- Give a generic pool or factory a Supplier<T> so it can create instances without new T().
More examples
A Class<T> token restores the check
item instanceof T would not compile, because T is erased. The Class object survives at runtime, so isInstance and cast do the check instead.
import java.util.ArrayList;
import java.util.List;
record Created(String id) {}
record Deleted(String id) {}
<T> List<T> ofType(List<?> items, Class<T> type) {
List<T> out = new ArrayList<>();
for (Object item : items) {
if (type.isInstance(item)) {
out.add(type.cast(item));
}
}
return out;
}
void main() {
List<Object> events = List.of(new Created("a"), new Deleted("b"), new Created("c"));
List<Created> created = ofType(events, Created.class);
List<Deleted> deleted = ofType(events, Deleted.class);
IO.println(created);
IO.println(deleted);
}
// Output:
// [Created[id=a], Created[id=c]]
// [Deleted[id=b]]A Supplier instead of new T()
The pool cannot construct a T itself, so the caller passes a constructor reference. The second borrow reuses the returned buffer instead of creating another.
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.function.Supplier;
class Pool<T> {
private final Supplier<T> factory;
private final Deque<T> free = new ArrayDeque<>();
private int created;
Pool(Supplier<T> factory) { this.factory = factory; }
T borrow() {
if (free.isEmpty()) {
created++;
return factory.get(); // new T() would not compile
}
return free.pop();
}
void giveBack(T item) { free.push(item); }
int created() { return created; }
}
void main() {
Pool<StringBuilder> buffers = new Pool<>(StringBuilder::new);
StringBuilder a = buffers.borrow();
a.append("response body");
a.setLength(0);
buffers.giveBack(a);
StringBuilder b = buffers.borrow();
IO.println(a == b);
IO.println("created: " + buffers.created());
}
// Output:
// true
// created: 1Reading a type argument from a subclass
Each anonymous subclass declares its superclass with the full type argument, and that declaration survives erasure. List.class on its own only knows its parameter is called E, which is why JSON libraries ask for a type reference to read a List<User>.
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.List;
import java.util.Map;
abstract class TypeRef<T> {
final Type type;
protected TypeRef() {
ParameterizedType superType = (ParameterizedType) getClass().getGenericSuperclass();
type = superType.getActualTypeArguments()[0];
}
}
void main() {
Type listOfStrings = new TypeRef<List<String>>() {}.type;
Type nested = new TypeRef<Map<String, List<Integer>>>() {}.type;
IO.println(listOfStrings);
IO.println(nested);
IO.println(List.class.getTypeParameters()[0]);
}
// Output:
// java.util.List<java.lang.String>
// java.util.Map<java.lang.String, java.util.List<java.lang.Integer>>
// E
Discussion