Skip to content

Generics and Type Erasure

Before generics (JDK 5, 2004), the Java collections API worked with Object. Every retrieval Required a cast, and the compiler could not verify that you were putting the right type in:

List names = new ArrayList();
names.add("Alice");
names.add(42); // compiles fine, bug at runtime
String name = (String) names.get(1); // ClassCastException at runtime

This was the standard pattern for container classes. The Collections framework, Hibernate, and Every library that dealt with heterogeneous data required the programmer to track types mentally. Incorrect casts surfaced as ClassCastException at runtime, often far from the actual insertion Point.

Generics introduced compile-time type checking with no runtime cost (via type erasure). The Compiler inserts casts for you and rejects code that violates type constraints:

List<String> names = new ArrayList<>();
names.add("Alice");
names.add(42); // compile error
String name = names.get(0); // no cast needed

The primary motivation was not performance but correctness: move type errors from runtime to Compile time.

The primary motivation was correctness: move type errors from runtime to compile time.

A generic class declares one or more type parameters after the class name. These parameters act as Type placeholders throughout the class body:

public class Box<T> {
private T value;
public Box(T value) {
this.value = value;
}
public T get() {
return value;
}
public void set(T value) {
this.value = value;
}
}

Usage:

Box<String> stringBox = new Box<>("hello");
Box<Integer> intBox = new Box<>(42);

Type parameters are single uppercase letters by convention:

ParameterMeaning
TType
EElement (collections)
KKey
VValue
S``USecond, third types
RReturn type

Multiple type parameters are comma-separated. Convention uses K for key, V for value, E for Element, S/U for secondary types.

Interfaces follow the same pattern:

public interface Repository<T, ID> {
Optional<T> findById(ID id);
List<T> findAll();
T save(T entity);
void deleteById(ID id);
}
public interface Comparable<T> {
int compareTo(T other);
}

A class can implement a generic interface with a concrete type or pass its own type parameter Through:

public class StringRepository implements Repository<String, Long> {
@Override
public Optional<String> findById(Long id) { /* ... */ }
}
public class InMemoryRepository<T, ID> implements Repository<T, ID> {
// passes type parameters through
}

A generic method declares its own type parameters before the return type. This allows the method to Be generic even if the enclosing class is not:

public class Util {
public static <T> T getFirst(List<T> list) {
if (list.isEmpty()) {
throw new IllegalArgumentException("empty list");
}
return list.get(0);
}
public static <K, V> Map<V, List<K>> invert(Map<K, V> map) {
Map<V, List<K>> result = new HashMap<>();
for (Map.Entry<K, V> entry : map.entrySet()) {
result.computeIfAbsent(entry.getValue(), k -> new ArrayList<>())
.add(entry.getKey());
}
return result;
}
}

The type parameters of a generic method are inferred by the compiler. Explicit type arguments are Rarely needed:

String first = Util.getFirst(List.of("a", "b", "c"));
// Explicit when inference is ambiguous:
// Util.<String, Integer>invert(someMap);

Generic constructors follow the same pattern — type parameters go before the constructor name:

public class IdentityWrapper<T> {
private final T value;
public <S extends T> IdentityWrapper(S value) { this.value = value; }
}
// IdentityWrapper<Number> w = new IdentityWrapper<>(42);
// S inferred as Integer, which extends Number

<T> means “any reference type.” The bound is implicitly Object. You can call only Object Methods on T:

public static <T> void print(T item) {
System.out.println(item.toString()); // OK, inherited from Object
System.out.println(item.length()); // compile error
}

<T extends SomeType> constrains T to SomeType or any subtype. Multiple bounds use & (the First bound can be a class, subsequent ones must be interfaces):

public static <T extends Comparable<T>> T max(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}
public static <T extends Number & Comparable<T>> T maxNumeric(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}

With a bound, you can call methods declared in the bound type:

public static <T extends Comparable<T>> void sort(List<T> list) {
list.sort(null); // T is known to be Comparable, so sort works
}

Upper bounds are the most common form. They say “I need to read from T” (I need it to be at Least this capable).

Lower bounds do not appear on type parameter declarations. They appear on wildcards (see next Section). A type parameter itself can only have upper bounds.

This distinction is important:

// VALID: type parameter with upper bound
public static <T extends Number> double sum(List<T> numbers) { /* ... */ }
// INVALID: type parameter with lower bound
// public static <T super Integer> void foo(List<T> list) { }
// Compile error — type parameters cannot have lower bounds

The Comparable interface uses a recursive bound: T extends Comparable<T>. This means “T must be Comparable to itself,” which is the standard contract for ordered types:

public static <T extends Comparable<T>> T max(List<T> list) {
if (list.isEmpty()) throw new IllegalArgumentException();
T result = list.get(0);
for (T item : list) {
if (item.compareTo(result) > 0) {
result = item;
}
}
return result;
}

When a type parameter has a bound, erasure replaces it with the first bound:

// erasure of <T extends Comparable<T>> is Comparable
// erasure of <T extends Number & Comparable<T>> is Number

The compiler is smart enough to let you call methods from any bound — it generates the appropriate Cast at the call site. But at the bytecode level, the erased type is only the first bound.

List<?> means “a list of some unknown type.” You can read from it (getting Object) but you Cannot write to it (except null):

void printSize(List<?> list) {
System.out.println(list.size()); // OK
Object obj = list.get(0); // OK, type is Object
list.add(null); // OK, null is a member of every reference type
list.add("hello"); // compile error
}

Use List<?> when you don”t care about the element type and only need read-only access.

List<? extends Number> means “a list of some type that is Number or a subtype.” You can read Number from it, but you cannot write anything meaningful to it:

void sum(List<? extends Number> numbers) {
double total = 0;
for (Number n : numbers) {
total += n.doubleValue(); // OK — every element is at least a Number
}
// numbers.add(3.14); // compile error — don't know if it's List<Double>
}

The key insight: List<? extends Number> could be List<Integer>``List<Double>Or List<Number>. The compiler cannot safely allow an add because the actual list type is unknown.

List<? super Integer> means “a list of some type that is Integer or a supertype.” You can write Integer (or its subtypes) to it, but you can only read Object:

void addIntegers(List<? super Integer> list) {
list.add(1); // OK — Integer is assignable to Integer, Number, or Object
list.add(2); // OK
Object obj = list.get(0); // OK — don't know the actual type
// Number n = list.get(0); // compile error
}

Joshua Bloch’s PECS principle (from Effective Java) is the canonical rule for choosing wildcard Directions:

  • If the parameter is a producer (you read from it, it gives you values) → use ? extends T
  • If the parameter is a consumer (you write to it, it takes values) → use ? super T

The Collections.copy method is the classic example:

// src produces elements → extends
// dest consumes elements → super
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (int i = 0; i < src.size(); i++) {
dest.set(i, src.get(i));
}
}
List<Integer> src = List.of(1, 2, 3);
List<Number> dest = new ArrayList<>(List.of(0.0, 0.0, 0.0));
Collections.copy(dest, src); // dest is ? super Integer, src is ? extends Integer
ScenarioUseCan read asCan write
Read-only (producer)? extends TTnothing (except null)
Write-only (consumer)? super TObjectT and subtypes of T
Read and writeT (no wildcard)TT
Don’t care?Objectnothing (except null)

When the compiler encounters a wildcard type, it internally creates a “capture” of the unknown type To track consistency. You can see this in error messages:

List<?> list = new ArrayList<String>();
// list.add("hello"); // error: ? capture#1 cannot be applied to add(String)
// Workaround: use a private helper method that captures the wildcard
private static <T> void addAndPrint(List<T> list, T item) {
list.add(item);
System.out.println(list);
}
List<?> list = new ArrayList<String>();
addAndPrint(list, "hello"); // T is inferred as String

This pattern — delegating to a generic helper method to “capture” a wildcard — is called the wildcard capture helper pattern.

Avoid wildcards in return types. They shift the burden onto the caller:

// BAD: caller must deal with wildcard
public List<? extends Number> getNumbers();
// GOOD: caller gets a concrete type
public List<Number> getNumbers();

Use wildcards in parameters (input), use concrete types in return types (output).

Java generics are implemented via type erasure. The compiler removes all generic type Information and replaces type parameters with their bounds (or Object if unbounded). Casts are Inserted where necessary.

// Source code
public class Box<T> {
private T value;
public T get() { return value; }
public void set(T value) { this.value = value; }
}
// After erasure (what the JVM sees)
public class Box {
private Object value;
public Object get() { return value; }
public void set(Object value) { this.value = value; }
}
// Source code
public class NumericBox<T extends Number> {
private T value;
public T get() { return value; }
}
// After erasure
public class NumericBox {
private Number value;
public Number get() { return value; }
}
// Source code
Box<String> box = new Box<>();
box.set("hello");
String s = box.get();
// After erasure
Box box = new Box();
box.set("hello");
String s = (String) box.get(); // compiler-inserted cast

The compiler inserts casts at every point where a generic type is narrowed from the erased type. This is the only runtime mechanism that enforces generic type safety.

Type erasure was a deliberate design decision to achieve backwards compatibility. The goals:

  1. Migration compatibility: Existing pre-generic code (raw types) must compile and run alongside generic code.
  2. Binary compatibility: Libraries compiled without generics must work with generic code without recompilation. The JVM class file format did not change for generics.
  3. No runtime performance cost: No generic-specific bytecodes. The JIT compiler optimizes away the inserted casts.

C# chose the opposite approach (reified generics), which provides richer runtime type information But broke backwards compatibility.

Consider a generic class that a subclass specializes:

public class Node<T> {
public T data;
public void setData(T data) { this.data = data; }
}
public class StringNode extends Node<String> {
@Override
public void setData(String data) { super.setData(data); }
}

After erasure, Node has setData(Object) and StringNode has setData(String). The compiler Generates a bridge method in StringNode:

// Compiler-generated bridge method in StringNode
public void setData(Object data) {
setData((String) data); // delegates to the actual override
}

Bridge methods are synthetic, marked with ACC_BRIDGE (0x0040) in the class file. They ensure Polymorphism works despite erasure. Verify with javap -v.

Bridge methods also handle covariant return types. If a generic method returns T and the subclass Narrows it, the bridge method handles the casting:

public class Animal { }
public class Cat extends Animal { }
public class Shelter<T extends Animal> {
public T adopt() { /* ... */ }
}
public class CatShelter extends Shelter<Cat> {
@Override
public Cat adopt() { return new Cat(); }
}
// Bridge method generated in CatShelter:
// public Animal adopt() { return (Animal) adopt(); }

You cannot use instanceof with parameterized types because the type parameter is erased:

if (list instanceof List<String>) // compile error — illegal generic type for instanceof
if (list instanceof List<?>) // OK since Java 8 for unbounded wildcards? No — still error
// Correct approach:
if (list instanceof List) // raw type check only

Use Class&lt;T&gt; tokens for runtime type checks (see the “Generic Type Tokens” section below).

Erasure can cause surprising overload conflicts:

public class Overload {
public void print(List<String> list) { }
public void print(List<Integer> list) { }
// Compile error: name clash — both erase to print(List)
}

The methods have the same erasure, so they are considered the same method signature. This is a Compile-time error.

Arrays are covariant in Java: String[] is a subtype of Object[]. Generics are invariant: List<String> is not a subtype of List<Object>. These two systems conflict:

String[] strings = new String[1];
Object[] objects = strings; // OK — arrays are covariant
objects[0] = 42; // ArrayStoreException at runtime
List<String> strings = List.of("a");
// List<Object> objects = strings; // compile error — generics are invariant

Arrays enforce type safety at runtime (ArrayStoreException). Generics enforce it at compile time. When you mix the two, things break:

// Compile error: cannot create a generic array
List<String>[] array = new List<String>[1];

This is illegal because if it were allowed:

List<String>[] strings = new List<String>[1]; // hypothetical
Object[] objects = strings;
objects[0] = List.of(42); // would pass compile-time array covariance check
String s = strings[0].get(0); // ClassCastException at runtime — heap pollution

The compiler prevents this by forbidding generic array creation.

Use Object[] with casts (what java.util.ArrayList does internally):

public class ArrayList<E> {
private Object[] elements;
@SuppressWarnings("unchecked")
public E get(int index) {
return (E) elements[index]; // unchecked cast
}
}

Use List<Class<T>> instead of Class<T>[]:

List<Class<T>> types = new ArrayList<>(); // instead of Class<T>[]

Varargs with generic types@SafeVarargs (JDK 7) suppresses the heap pollution warning for Varargs methods where the caller does not modify the array:

@SafeVarargs
public static <T> List<T> of(T... elements) { return List.of(elements); }

When you mix raw types and generic types, the compiler issues unchecked warnings. Use @SuppressWarnings("unchecked") sparingly and document why it is safe:

@SuppressWarnings("unchecked")
public <T> T[] toArray(T[] array) {
return (T[]) Arrays.copyOf(elements, array.length, array.getClass());
}

Reified Types and Why new T[] Doesn’t Work

Section titled “Reified Types and Why new T[] Doesn’t Work”

A reified type is one that retains its full type information at runtime. In Java:

  • Primitive types are reified: int.class``double.class exist at runtime.
  • Regular class types are reified: String.class``List.class exist at runtime.
  • Generic type parameters are NOT reified: List<String>.class does not exist. At runtime, List<String> and List<Integer> are both just List.

This is the direct consequence of type erasure. The JVM has no way to distinguish between List<String> and List<Integer> at runtime.

public class Container<T> {
private T[] items;
public Container(int size) {
items = new T[size]; // compile error: type parameter T cannot be instantiated directly
}
}

The compiler cannot generate the correct bytecode because it doesn’t know what type T represents At runtime. It cannot emit newarray or anewarray without a concrete class reference.

public class Factory<T> {
public T create() {
return new T(); // compile error: type parameter T cannot be instantiated directly
}
}

Same reason: the erased code would need to be return new Object() which is wrong for any T other Than Object.

Pass a Supplier<T>:

public class Factory<T> {
private final Supplier<T> constructor;
public Factory(Supplier<T> constructor) { this.constructor = constructor; }
public T create() { return constructor.get(); }
}
Factory<String> f = new Factory<>(String::new);

Pass a Class<T> token:

public static <T> T create(Class<T> clazz) throws Exception {
return clazz.getDeclaredConstructor().newInstance();
}

Class.newInstance() (deprecated since JDK 9) is replaced by clazz.getDeclaredConstructor().newInstance().

Array of Object with cast (unsafe for any T other than Object):

@SuppressWarnings("unchecked")
public static <T> T[] newArray(int size) {
return (T[]) new Object[size]; // runtime type is Object[], not T[]
}

Array from a Class<T> token (correct approach):

@SuppressWarnings("unchecked")
public static <T> T[] newArray(Class<T> componentType, int size) {
return (T[]) java.lang.reflect.Array.newInstance(componentType, size);
}

Generic Type Tokens with the Class<T> Pattern

Section titled “Generic Type Tokens with the Class<T> Pattern”

The Super Type Token (GSON/Jackson pattern)

Section titled “The Super Type Token (GSON/Jackson pattern)”

Since List<String>.class doesn’t exist, you need a way to preserve generic type information at Runtime. The super type token pattern exploits the fact that a class’s generic superclass Signature is preserved in the class file:

import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
public abstract class TypeReference<T> {
private final Type type;
protected TypeReference() {
Type superClass = getClass().getGenericSuperclass();
if (superClass instanceof ParameterizedType pt) {
this.type = pt.getActualTypeArguments()[0];
} else {
throw new IllegalArgumentException("Missing type parameter");
}
}
public Type getType() {
return type;
}
}

Usage:

// The anonymous subclass captures List<String> in its superclass type signature
TypeReference<List<String>> token = new TypeReference<List<String>>() {};
Type type = token.getType(); // java.util.List<java.lang.String>

This is how Jackson’s TypeReference<T> and GSON’s TypeToken<T> work. The anonymous inner class Carries the generic type argument in its class metadata, which reflection can read.

For non-parameterized types, Class<T> serves as a runtime type token:

public <T> String serialize(T obj, Class<T> type) {
if (type == String.class) return "\"" + obj + "\"";
if (type == Integer.class) return String.valueOf(obj);
return obj.toString();
}

Combine Class<T> with bounded type parameters for type-safe configuration reading:

public static <T extends Comparable<T>> T readConfig(
String key, Class<T> type, T defaultValue) {
String value = System.getProperty(key);
if (value == null) return defaultValue;
if (type == Integer.class) return type.cast(Integer.valueOf(value));
if (type == Long.class) return type.cast(Long.valueOf(value));
return type.cast(value);
}
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true — both are ArrayList
System.out.println(a.getClass()); // class java.util.ArrayList

There is no way to recover String or Integer from a.getClass() or b.getClass(). The type Parameter is gone at runtime.

Heap pollution occurs when a variable of a parameterized type refers to an object that is not of That type. This happens through unchecked casts or mixing raw and generic types:

List raw = new ArrayList<Integer>();
raw.add("not an integer"); // heap pollution
List<Integer> integers = raw; // no warning — raw type assignment
int x = integers.get(0); // ClassCastException: String cannot be cast to Integer

Varargs with generic types are a common source of heap pollution:

public static <T> void addToList(List<T> list, T... elements) {
// internally, elements is T[] which erases to Object[]
// The compiler creates an Object[] at the call site
for (T e : elements) {
list.add(e);
}
}
// Call site
List<String> strings = new ArrayList<>();
addToList(strings, "a", "b"); // compiler creates new Object[]{"a", "b"}
// Dangerous call site
List<String> strings = new ArrayList<>();
List<Integer> ints = new ArrayList<>();
addToList(strings, ints.toArray()); // Object[] of Integer assigned to T... where T=String

Use @SafeVarargs only when the method does not store the varargs array or expose it to code that Might perform unsafe operations.

Unchecked warnings indicate that the compiler cannot verify type safety. The most common sources:

  1. Raw type usage: List list = new ArrayList<>();
  2. Unchecked casts: (List<String>) someObject
  3. Generic array creation: new List<String>[1]
  4. Varargs with non-reifiable types: void foo(List<String>... args)

Never suppress unchecked warnings without understanding why they are safe. Document the safety Argument:

// Safe because elements is only read, never written to, and only T values were inserted
@SuppressWarnings("unchecked")
public <T> T[] toArray(T[] array) {
return (T[]) Arrays.copyOf(elements, array.length, array.getClass());
}

instanceof doesn’t work with generics:

if (obj instanceof List<String>) // compile error

Cast to generic type is unchecked — erasure reduces it to (List) at runtime:

List<String> list = (List<String>) obj; // unchecked cast warning

Cannot throw or catch generic types — the JVM needs the exception class at runtime:

try { } catch (T e) { } // compile error

Static fields cannot reference type parameters — shared across all Box<T> instances:

public class Box<T> {
private static T value; // compile error
}

ClassCastException at the wrong location — erasure delays the check to the inserted cast:

List<Integer> list = new ArrayList<>();
List raw = list;
raw.add("oops"); // no error here
Integer i = list.get(0); // ClassCastException HERE

Never use raw types in new code. They disable all generic type checking:

List list = new ArrayList(); // BAD
List<Object> list = new ArrayList<>(); // GOOD
List<String> list = new ArrayList<>(); // BETTER

The one exception is Class literals: List.class (not List<String>.class).

Java’s type inference has improved over releases but still has limits. Target type inference (JDK 8) Allows type parameters to be inferred from assignment context, but deeply nested generic types can Confuse the compiler.

public class StringList extends ArrayList<String> { } // valid — fixes T to String
public class MyList implements List<String>, List<Integer> {
// compile error — both erase to List
}

When extending a generic class, you can fix the type parameter, pass it through, or add constraints — but you cannot have conflicting erasures.

This topic covers the core concepts of generics and type erasure, including underlying theory, practical implementation, and key applications.

Key concepts include:

  • OOP principles (encapsulation, inheritance, polymorphism)
  • collections framework
  • streams and lambda expressions
  • exception handling
  • the JVM and garbage collection

Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.

Generics are like a type-safe shipping container — they ensure that what goes in is what comes out, preventing the runtime ClassCastException that plagued pre-generic Java. Type erasure is the customs process: the compiler strips away the type information at the border (compilation), and only the runtime cast remains. The PECS principle (Producer Extends, Consumer Super) is the loading rule: if you are reading from a container, use extends; if you are writing to it, use super. The challenge is that generics create a tension between compile-time safety and runtime flexibility — you get strong type checking at the cost of some runtime information loss.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.