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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

How to Avoid Burnout When Working Solo

Working alone can feel liberating—until it feels like a trap. Here’s how to stay sane, productive, and motivated without a team.

Read more

Client Office Requirements That Kill Contractor Efficiency

“Just come to the office five days a week and use our setup.” It sounds normal—until you realize how much productivity quietly disappears.

Read more

Employee vs Contractor: The Real Financial Difference

Why that “expensive” contractor rate isn’t as simple as it looks (and why employees aren’t as cheap as they seem)

Read more

When Git Is Prohibited: Why Use Modern Tools When You Can Hand Over Code Like It’s 1999?

Remember the days before Git, CI/CD, and proper version control? Some managers seem determined to bring us back—one Word document at a time.

Read more