☕ Java

Type Erasure

Type erasure is the mechanism by which the Java compiler removes all generic type information during compilation, replacing type parameters with their erasures — either Object or the first bound of a bounded type parameter — and inserting casts where necessary to preserve type safety at the call site. A List<String> and a List<Integer> become indistinguishable List objects at runtime; no generic type information survives into bytecode. Type erasure was chosen as the implementation strategy for generics in Java 5 to preserve binary compatibility with pre-generics bytecode: existing .class files compiled without generics could interoperate with new generic code without recompilation. This entry covers how erasure works for type parameters and wildcards, bridge methods and why the compiler generates them, the specific runtime consequences of erasure (heap pollution, unchecked warnings, instanceof limitations, reflection behavior), and patterns for working around erasure including the type token pattern and super type tokens.

How Erasure Works — Type Parameters, Bounds, and Bridge Methods

Type erasure operates on every generic declaration in a Java program. For an unbounded type parameter <T>, the compiler replaces every occurrence of T with Object. For a bounded type parameter <T extends Comparable<T>>, every occurrence of T is replaced with Comparable (the first — and only enforceable — bound). For a wildcard ? or ? extends T or ? super T, the erasure is Object, the upper bound, or Object respectively. The resulting bytecode contains no angle brackets, no type parameters, and no wildcard information. Only raw types and casts remain. A concrete illustration: the class Pair<A, B> with fields A first and B second erases to a class Pair with fields Object first and Object second. A method <T extends Serializable & Cloneable> T process(T value) erases to Serializable process(Serializable value) because Serializable is the first bound. The compiler inserts a cast to Cloneable wherever the Cloneable aspect of T is actually used. Bridge methods are a direct consequence of erasure and inheritance. When a generic class or interface is subclassed with a concrete type argument, the erasure of the parent's methods may not match the overriding methods in the subclass. The compiler generates synthetic bridge methods to maintain polymorphism. Consider Comparable<T>: its compareTo(T o) method erases to compareTo(Object o). A class Integer implements Comparable<Integer>, so its compareTo(Integer o) method does not override the erased compareTo(Object o) from the interface. The compiler generates a bridge: public int compareTo(Object o) { return compareTo((Integer) o); }. This bridge is present in bytecode and is visible via reflection, though it is not callable directly in source code. The erasure of a generic method signature also affects overloading. Two methods that differ only in their type arguments — void process(List<String> list) and void process(List<Integer> list) — have identical erasures: void process(List list). The compiler rejects this as a duplicate method declaration, even though they appear distinct at the source level. This is one of the most practically significant constraints erasure imposes on API design.
Java
// ── Erasure of type parameters ───────────────────────────────────────
// Source:
class Box<T> {
    private T value;
    public T get() { return value; }
    public void set(T value) { this.value = value; }
}

// After erasure (what the compiler actually emits):
class Box {
    private Object value;            // T → Object (unbounded)
    public Object get() { return value; }
    public void set(Object value) { this.value = value; }
}

// ── Erasure with bounded type parameters ─────────────────────────────
// Source:
class SortedPair<T extends Comparable<T>> {
    T first, second;
    public int compare() { return first.compareTo(second); }
}

// After erasure (T → Comparable, the first bound):
class SortedPair {
    Comparable first, second;
    public int compare() { return first.compareTo(second); }
    // compareTo(second) works because erasure of Comparable<T> is Comparable
}

// ── Compiler-inserted casts — transparent in source, visible in bytecode
Box<String> box = new Box<>();
box.set("hello");
String s = box.get();   // looks like get() returns String

// Bytecode equivalent (what actually runs at the JVM level):
// Object temp = box.get();         // erased return type is Object
// String s = (String) temp;        // compiler-inserted checkcast instruction
// The cast is safe because the compiler tracked type info during compilation.

// ── Overloading and erasure collision ─────────────────────────────────
class Processor {
    // These two methods have IDENTICAL erasures: void handle(List)
    // void handle(List<String> list) { }   // COMPILE ERROR
    // void handle(List<Integer> list) { }  // COMPILE ERROR — "duplicate method"

    // Fix: use different method names, or a different structural parameter
    void handleStrings(List<String> list) { }
    void handleIntegers(List<Integer> list) { }
}

// ── Bridge methods — visible via reflection ───────────────────────────
import java.lang.reflect.Method;

class StringComparable implements Comparable<String> {
    @Override
    public int compareTo(String o) { return this.toString().compareTo(o); }
    // Compiler generates bridge:
    // public int compareTo(Object o) { return compareTo((String) o); }
}

for (Method m : StringComparable.class.getDeclaredMethods()) {
    System.out.println(m.isBridge() + " " + m.getName() + " " + m.getParameterTypes()[0]);
}
// false  compareTo  class java.lang.String   (the one you wrote)
// true   compareTo  class java.lang.Object   (bridge, compiler-generated)

// ── Generics vanish completely at runtime ─────────────────────────────
List<String> strings  = new ArrayList<>();
List<Integer> integers = new ArrayList<>();

System.out.println(strings.getClass() == integers.getClass());  // true — both are ArrayList
System.out.println(strings.getClass().getName());               // java.util.ArrayList

// There is no List<String>.class — only List.class:
// Class<?> c = List<String>.class;   // COMPILE ERROR — generic type has no class literal

Runtime Consequences — Heap Pollution, instanceof, and Unchecked Warnings

Because generic type information is absent at runtime, several operations that would seem natural in a strongly typed language are either prohibited by the compiler or quietly unsafe. Understanding each consequence follows directly from the single fact that only raw types exist at runtime. Heap pollution occurs when a variable of a parameterized type refers to an object that is not of that parameterized type. Erasure makes this possible despite the type system: a List<String> variable can, through raw types or unchecked casts, end up pointing at an ArrayList that actually contains Integer objects. The pollution is invisible until a read triggers the compiler-inserted cast, at which point a ClassCastException is thrown at a location in code far from where the incorrect insertion happened. This is why the compiler emits an "unchecked" warning whenever an operation could introduce heap pollution — the check that would normally catch the error cannot be performed at compile time because the types have been erased. The instanceof operator cannot interrogate generic type arguments because there is no generic type information in the object to interrogate. instanceof List<String> is a compile-time error. instanceof List<?> is legal (the unbounded wildcard is reifiable — it has a runtime representation as List) but tests only whether the object is a List, not whether it contains Strings. Reifiable types are types whose full type information survives erasure: primitive types, non-generic class and interface types, raw types, and unbounded wildcards. Generic instantiations like List<String>, Map<K,V>, and Optional<Integer> are non-reifiable; only their erasures (List, Map, Optional) are reifiable. The @SuppressWarnings("unchecked") annotation suppresses unchecked warnings but does not make the operation safe — it only tells the compiler and reader that you have manually verified the safety invariant. The correct use of this annotation requires a comment explaining why the cast is safe. Using it to silence warnings without verification is the source of most latent ClassCastExceptions in production Java code. Generic arrays cannot be created: new T[] is a compile-time error, and new List<String>[] is also a compile-time error. The reason is that arrays are covariant and their element type is checked at runtime (via ArrayStoreException), but erased generics have no runtime type to check. A List<String>[] after erasure would just be a List[], and inserting a List<Integer> into it would not throw ArrayStoreException because the runtime sees only List. Creating such an array would guarantee heap pollution.
Java
// ── Heap pollution — how it happens ─────────────────────────────────
@SuppressWarnings("unchecked")
List<String> polluted = (List<String>) (List<?>) new ArrayList<>(List.of(1, 2, 3));
// No exception here — erasure means the JVM sees only ArrayList

// Exception thrown HERE, not where the bad data entered:
String first = polluted.get(0);   // ClassCastException: Integer cannot be cast to String
// The cast was inserted by the compiler at the read site, not the write site.

// ── Varargs + generics = automatic heap pollution source ──────────────
// Safe-looking call site:
static void addToLists(List<String>... lists) {    // COMPILE WARNING: varargs heap pollution
    Object[] array = lists;                         // arrays are covariant — legal
    array[0] = new ArrayList<Integer>();            // no ArrayStoreException — just List[]
    String s = lists[0].get(0);                     // ClassCastException at runtime
}

// @SafeVarargs suppresses warnings when you have manually verified safety:
@SafeVarargs
static <T> List<T> concat(List<? extends T>... lists) {
    List<T> result = new ArrayList<>();
    for (List<? extends T> list : lists) result.addAll(list);
    return result;   // safe: only reads from lists, never writes via the varargs array
}

// ── instanceof and reifiability ───────────────────────────────────────
Object obj = new ArrayList<String>();

// Legal instanceof forms:
System.out.println(obj instanceof List<?>);       // true  — unbounded wildcard is reifiable
System.out.println(obj instanceof ArrayList<?>);  // true  — reifiable
System.out.println(obj instanceof List);          // true  — raw type is reifiable

// Illegal instanceof forms (compile errors):
// obj instanceof List<String>    — COMPILE ERROR, generic type is not reifiable
// obj instanceof T               — COMPILE ERROR inside generic method, T erased

// ── Reifiable vs non-reifiable — practical decision table ────────────
// Reifiable (instanceof OK):
//   int, long, boolean ...   (primitives)
//   String, Integer, Object  (non-generic classes)
//   List, Map, Optional      (raw types)
//   List<?>, Map<?,?>        (unbounded wildcards)
//   int[]                    (primitive arrays)
//
// Non-reifiable (instanceof illegal):
//   List<String>, Map<String,Integer>, Optional<T>  (generic instantiations)
//   T, E, K, V                                      (type parameters)

// ── Generic arrays — why they are banned ─────────────────────────────
// List<String>[] arr = new List<String>[10];   // COMPILE ERROR

// Why: arrays enforce element type at runtime (ArrayStoreException):
Object[] strings = new String[3];
strings[0] = 42;   // ArrayStoreException — runtime knows element type is String

// But a List[] (the erasure of List<String>[]) does NOT know element type:
// Object[] listsArr = new List[3];
// listsArr[0] = new ArrayList<Integer>();  // no ArrayStoreException — poison inserted silently

// Workaround: use List<List<String>> or suppress with @SuppressWarnings("unchecked"):
@SuppressWarnings("unchecked")
List<String>[] safeIfYouKnowWhatYoureDoing = new List[10];  // raw type array, manual discipline

// ── Unchecked warning: suppress only with documented proof of safety ──
// BAD — silences without verifying:
@SuppressWarnings("unchecked")
Map<String, Integer> map = (Map<String, Integer>) getUntypedMap();

// GOOD — documents the invariant:
// The contract of getUntypedMap() guarantees keys are String, values are Integer.
// No external code can access the backing map, so no heap pollution is possible.
@SuppressWarnings("unchecked")
Map<String, Integer> map2 = (Map<String, Integer>) getUntypedMap();

Working Around Erasure — Type Tokens, Super Type Tokens, and Reflection

When runtime type information about a generic parameter is genuinely needed, the standard approach is to pass a Class<T> object as an explicit parameter. This is the type token pattern: rather than relying on T being available at runtime (it isn't), the caller supplies the Class object representing T, which is available at runtime and carries the needed type information. The type token is used for instanceof checks, casting, and reflection. Class<T> as a type token works well for simple, non-generic types: Class<String>, Class<Integer>, Class<MyDomainClass>. It fails for generic instantiations: there is no Class<List<String>> because List<String> is non-reifiable. There is only Class<List>, which has lost the String information. This is where the super type token pattern (popularized by Neal Gafter and used in Jackson, Gson, Guice, Spring, and many other frameworks) fills the gap. Super type tokens exploit a specific feature of reflection: when a class or anonymous class extends a generic superclass, the generic supertype information is not erased. It is preserved in the .class file as a generic signature attribute and is accessible via getGenericSuperclass() on the Class object. An anonymous class new TypeReference<List<String>>() {} extends TypeReference<List<String>>, and that parameterization — including the String — is retained in the bytecode and accessible at runtime through reflection. Frameworks use this to allow callers to describe complex generic types like Map<String, List<Integer>> in a way that survives erasure. Reflection and erasure interact in a specific way: Method.getGenericReturnType(), Field.getGenericType(), and the related methods return Type objects (not Class objects) that may implement ParameterizedType, GenericArrayType, TypeVariable, or WildcardType. These are compile-time signatures stored in class files, not runtime type tags on objects. They let you inspect what a method was declared to return (List<String>) even though at runtime all you have is a List. The key insight is that class-level generic declarations are stored in bytecode; it is the instantiation of type parameters on objects that is erased.
Java
// ── Type token pattern — Class<T> as explicit runtime type info ───────
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) {
        Object raw = store.get(key);
        return type.cast(raw);   // safe cast using runtime Class<T>
    }

    public boolean contains(String key) {
        return type.isInstance(store.get(key));   // instanceof via Class
    }
}

TypedCache<String> cache = new TypedCache<>(String.class);
cache.put("greeting", "hello");
String s = cache.get("greeting");  // no unchecked cast, type-safe

// Limitation: no Class<List<String>> — only Class<List> (raw):
// TypedCache<List<String>> broken = new TypedCache<>(List<String>.class);  // COMPILE ERROR

// ── Super type token — capturing generic types at runtime ─────────────
abstract class TypeToken<T> {
    private final Type type;

    protected TypeToken() {
        // getGenericSuperclass() returns ParameterizedType for TypeToken<List<String>>
        ParameterizedType superclass = (ParameterizedType) getClass().getGenericSuperclass();
        this.type = superclass.getActualTypeArguments()[0];
    }

    public Type getType() { return type; }

    @Override public String toString() { return type.getTypeName(); }
}

// Usage: anonymous subclass captures the full generic type in bytecode:
TypeToken<List<String>>        listOfString = new TypeToken<List<String>>() {};
TypeToken<Map<String,Integer>> mapType      = new TypeToken<Map<String,Integer>>() {};

System.out.println(listOfString);  // java.util.List<java.lang.String>
System.out.println(mapType);       // java.util.Map<java.lang.String, java.lang.Integer>

// The type is now available as a ParameterizedType at runtime:
ParameterizedType pt = (ParameterizedType) listOfString.getType();
System.out.println(pt.getRawType());                   // interface java.util.List
System.out.println(pt.getActualTypeArguments()[0]);    // class java.lang.String

// ── Reflection and generic signatures — what IS preserved ────────────
import java.lang.reflect.*;

class Repository {
    public List<String> findAll() { return List.of(); }
    public <T extends Serializable> Map<String, T> findByType(Class<T> type) { return Map.of(); }
}

Method findAll = Repository.class.getMethod("findAll");

// getReturnType() gives the erased type:
System.out.println(findAll.getReturnType());             // interface java.util.List  (erased)

// getGenericReturnType() gives the full declared type from bytecode signatures:
System.out.println(findAll.getGenericReturnType());      // java.util.List<java.lang.String>

ParameterizedType returnPT = (ParameterizedType) findAll.getGenericReturnType();
System.out.println(returnPT.getActualTypeArguments()[0]);  // class java.lang.String

// ── What is and is not preserved after erasure ────────────────────────
// ERASED (not available at runtime on objects):
//   - Type arguments on object instances: new ArrayList<String>() loses <String>
//   - Type arguments in local variable declarations: List<String> x — erased
//   - Type parameters inside method bodies

// PRESERVED (accessible via reflection):
//   - Generic superclass/interfaces of a class: getGenericSuperclass(), getGenericInterfaces()
//   - Generic return types/parameter types/field types: getGenericReturnType() etc.
//   - Type parameter declarations on classes/methods: getTypeParameters()
//   - Generic type info in anonymous subclasses that capture a supertype (super type token)

class StringList extends ArrayList<String> {}   // <String> is preserved here
Type supertype = StringList.class.getGenericSuperclass();
System.out.println(supertype);  // java.util.ArrayList<java.lang.String>

Related Topics in Generics

Generic Class
A generic class in Java is a class parameterized by one or more type parameters, written as class Box<T> or class Pair<K, V>. Type parameters allow a single class definition to work correctly with many different types while preserving full compile-time type safety — eliminating the need for casts, preventing ClassCastException at runtime, and making the intended type contract explicit in the API. The compiler erases type parameters at bytecode level (type erasure), replacing them with their bounds or Object, but all type checking occurs at compile time before erasure. This entry covers type parameter declaration and naming conventions, bounded type parameters (extends, super), multiple type parameters, raw types and why they are unsafe, type erasure and its consequences, wildcards (?, ? extends T, ? super T), the PECS principle, recursive type bounds, generic interfaces, inheritance with generic classes, and how to design generic classes that work correctly across all parameterizations.
Generic Method
A generic method is a method that declares its own type parameters, independent of any type parameters on its enclosing class. The type parameters appear in angle brackets before the return type: public static <T> T identity(T value). Generic methods can appear in non-generic classes, in generic classes (adding their own type parameters beyond the class's), and as both static and instance methods. The compiler infers the type arguments for generic method calls from the argument types, eliminating the need for explicit type witness syntax in most cases. This entry covers type parameter declaration on methods, type inference rules and when inference fails, bounded type parameters on methods, returning computed type relationships, generic methods that operate on multiple type parameters, static generic utility methods (the standard library pattern), the interaction between method type parameters and class type parameters, explicit type witness syntax, and the difference between using a class type parameter vs a method type parameter.
Type Parameters
Type parameters are the placeholders — written as single uppercase letters like T, E, K, V — that make Java generics work. A type parameter declares a variable that stands in for a concrete type, to be supplied by the caller or inferred by the compiler. They appear on classes (class Box<T>), interfaces (interface Comparable<T>), and methods (public static <T> T identity(T value)), and they are the mechanism by which the Java type system achieves compile-time type safety without duplicating code for each concrete type. This entry covers where and how type parameters are declared, the naming conventions and why they exist, the scope rules for type parameters (where they are in scope and where they are not), how type parameters interact with inheritance, how the compiler resolves a type parameter to a concrete type at each call site, and the distinction between a type parameter (a declaration) and a type argument (a concrete substitution).
Bounded Types
Bounded type parameters constrain which concrete types may be substituted for a type parameter. An upper bound (T extends Number) restricts T to Number and its subtypes, making Number's methods available on values of type T within the generic code. A lower bound cannot be applied to type parameters (only to wildcards), but multiple upper bounds can be combined with & (T extends Number & Comparable<T> & Serializable). Bounds serve two purposes simultaneously: they restrict the valid type arguments that callers can supply, and they expand what the generic code can do with values of type T by exposing the bound's API. Without a bound, only Object's methods are available on T values. This entry covers single and multiple upper bounds, recursive bounds, bounds on class type parameters vs method type parameters, how bounds interact with type erasure, bridge methods generated by the compiler, and practical patterns where bounds enable algorithms impossible with unbounded type parameters.