Skip to main content

Generics and Type Erasure

Medium

Interview Question: "What fundamental problems did Generics solve when introduced in Java 5? How does the JVM execute generics under the hood via Type Erasure, and what practical limitations does this architectural choice impose?"

The short version​

Before Java 5, every collection just stored plain Object references. You could put anything in, and you only found out you'd put the wrong thing in when the program crashed at runtime with a ClassCastException. Generics let you label a collection with the type it's supposed to hold, so the compiler catches the mistake before the code ever runs.

Here's the twist: at runtime, the JVM doesn't actually know about any of that labeling. Java erases the generic type information at compile time - it checks everything while compiling, inserts the casts you'd have written by hand, and then throws the type labels away. This is called type erasure, and it's a deliberate trade-off: it kept Generics 100% compatible with the millions of lines of pre-Java-5 code already running in production.


The problem, before and after​

// BEFORE: raw collections - anything goes, compiler won't stop you
List rawList = new ArrayList();
rawList.add("Alice");
rawList.add(100); // compiles fine - Object accepts anything

String name = (String) rawList.get(0); // works
String number = (String) rawList.get(1); // compiles, then crashes at RUNTIME
// AFTER: generics - the compiler enforces the type for you
List<String> safeList = new ArrayList<>();
safeList.add("Alice");

// safeList.add(100);
// COMPILE ERROR - caught immediately, never reaches production

String name = safeList.get(0); // no manual cast needed

Two real wins: mistakes become compile errors instead of 3 AM production crashes, and you stop writing manual casts everywhere.


The analogy: a labeled box with a bouncer​

A raw type is an unlabeled cardboard box. Anyone can toss in an apple, a shoe, whatever - and when you reach in blind expecting an apple, you might get a shoe instead (ClassCastException).

A generic type is the same box with a label on it (<Apple>), and the compiler acts as a bouncer standing at the opening: anything that isn't an apple gets rejected before it goes in.

Type erasure is what happens once everyone's safely inside: the bouncer tears up the tickets at the door. Inside, the JVM just sees a box of Objects - it doesn't need to keep checking, because the bouncer already verified everything on the way in.


What erasure actually does to your code​

You write
List<String> list
compiler checks every add() and get()
erased at compile time
JVM runs
List list
raw type - Object references, plus an inserted checkcast

javac does three things when it compiles generic code:

  1. Replaces the type parameter with its bound - an unbounded <T> becomes Object; a bounded <T extends Number> becomes Number.
  2. Inserts casts at the call sites that read values back out, so the bytecode still behaves correctly.
  3. Generates bridge methods where needed, so overriding still works correctly across the erasure.
// You write:
public class DataHolder<T> {
private T value;
public T getValue() { return value; }
}

// The bytecode is equivalent to:
public class DataHolder {
private Object value;
public Object getValue() { return value; }
}
// Bounded version, you write:
public class NumericStats<T extends Number> {
private T number;
public double toDouble() { return number.doubleValue(); }
}

// Erased to its bound:
public class NumericStats {
private Number number;
public double toDouble() { return number.doubleValue(); }
}

Bridge methods, explained: say Node implements Comparable<Node> with compareTo(Node o). After erasure, Comparable<T>'s method becomes compareTo(Object) - so Node's method no longer technically overrides it at the bytecode level. The compiler quietly adds a bridge to fix this:

// generated silently by the compiler
public synthetic bridge int compareTo(Object o) {
return this.compareTo((Node) o);
}

Generics vs. raw types vs. C++ templates​

DimensionRaw types (pre-Java 5)Java generics (erasure)C++ templates (reified)
Type safetyNone - caught only at runtimeStrong, at compile timeStrong, at compile time
Known at runtimeOnly ObjectErased - type argument is goneReified - full type info kept
Binary sizeOne class fileOne class file, shared by every type usedNew code generated per type (vector<int>, vector<double>, ...)
CompatibilityLegacy baseline100% compatible with pre-2004 bytecodeNeeds recompilation from source
PrimitivesN/ANot allowed directly - List<Integer>, not List<int>Supported natively, no boxing
Runtime costManual cast overheadSame as raw types, plus an inserted castFastest - the compiler can inline aggressively

What erasure makes illegal​

Because the type argument is gone by runtime, a few things you'd expect to work, don't:

1. You can't do new T()

public class Factory<T> {
public T create() {
// return new T(); // COMPILE ERROR
return null;
}
}

The JVM doesn't know what T actually is at runtime, so it has no idea which constructor to call. Workaround: pass in a Class<T> token and call clazz.getDeclaredConstructor().newInstance().

2. You can't instanceof a parameterized type

List<String> list = new ArrayList<>();
// if (list instanceof List<String>) { ... } // COMPILE ERROR
if (list instanceof List<?>) { ... } // fine - unbounded wildcard

At runtime, List<String> and List<Integer> are both just ArrayList - there's nothing left to check against.

3. You can't make a generic array

// T[] items = new T[10]; // COMPILE ERROR
@SuppressWarnings("unchecked")
T[] items = (T[]) new Object[10]; // the standard JDK workaround

Arrays remember their exact element type at runtime (so they can throw ArrayStoreException); generics don't. The two systems don't mix.

4. You can't use a primitive as a type argument

// List<int> numbers = new ArrayList<>(); // SYNTAX ERROR
List<Integer> numbers = new ArrayList<>(); // use the boxed type instead

Erasure replaces an unbounded type with Object, and primitives aren't objects.


The overload trap​

A classic trick question:

public class Printer {
public void print(List<String> stringList) {
System.out.println("Printing strings");
}

public void print(List<Integer> intList) {
System.out.println("Printing integers");
}
}

This doesn't compile. Both methods erase to the same signature, print(List list), so the compiler rejects it with "has the same erasure as another method." You'd need different method names (printStrings, printIntegers) instead.


Say this in the interview​

"Generics gave us compile-time type safety and got rid of manual casts and runtime ClassCastExceptions. Under the hood, Java implements this through type erasure: the compiler checks everything, inserts the casts you'd otherwise write by hand, and then strips the generic type information out of the bytecode entirely - an unbounded <T> becomes Object, a bounded <T extends Number> becomes Number. This keeps full backward compatibility with pre-Java-5 bytecode and avoids the code bloat that C++ templates have, but it's why you can't do new T(), can't instanceof a parameterized type, can't create a generic array, and can't use primitives as type arguments."