Definition
- Type erasure – The compiler's process of removing generic type information after compile-time checking, replacing type parameters with their bounds or
Object, and inserting casts. - Runtime consequence –
ListandListare the same class at runtime (rawList), so the JVM has no knowledge of the element type. - Impossible operations –
new T(),instanceof List,new T[], and overloading by generic type argument all fail because of erasure. - Class token workaround – Pass a
Classto recover type information at runtime, e.g.,clazz.getDeclaredConstructor().newInstance(). - PECS – Producer Extends, Consumer Super: use
? extends Twhen reading from a structure and? super Twhen writing into one.
Imagine National Bank of India stamps every form with a label like "this box only accepts savings-account records" while the clerk checks the forms (compile time). But once the forms are filed, the label is peeled off and every box just says "records" (runtime erasure). So later you cannot ask a filed box "were you originally the savings-only box?" because the label is gone. PECS is the handling rule: if you are reading records out of a box, label it "produces account records" so you can read safely; if you are putting records in, label it "accepts accounts or any parent type" so writes are valid.
Understanding the Concepts
What is type erasure?
At compile time the compiler fully checks generics. But after checking, it erases the type parameter: an unbounded T becomes Object, a bounded T extends Number becomes Number, and it inserts casts at call sites. The consequence is that List and List are the same class at runtime.
package com.bank.service;
import java.util.ArrayList;
import java.util.List;
public class ErasureDemo {
public void demonstrateErasure() {
List<String> accounts = new ArrayList<>();
List<Integer> numbers = new ArrayList<>();
// At runtime, both are just ArrayList
System.out.println(
accounts.getClass() == numbers.getClass()); // true
// The generic type is gone at runtime
System.out.println(
accounts.getClass().getName()); // java.util.ArrayList
}
}
Why can you not write new T()?
At runtime T is erased to its bound, so the JVM does not know which constructor to call. The standard workaround is to pass a Class token and use reflection.
package com.bank.repository;
import java.math.BigDecimal;
public class GenericRepository<T> {
private final Class<T> entityClass;
// Pass Class<T> token to recover type at runtime
public GenericRepository(Class<T> entityClass) {
this.entityClass = entityClass;
}
public T createNew() throws Exception {
// new T() is impossible, so use reflection
return entityClass.getDeclaredConstructor().newInstance();
}
}
// Usage
var repo = new GenericRepository<>(SavingsAccount.class);
SavingsAccount account = repo.createNew();
Why can you not write instanceof List?
The runtime only has raw List, with no memory of the element type. You can only test instanceof List> (unbounded wildcard).
package com.bank.service;
import java.util.List;
public class InstanceofDemo {
public void checkType(Object obj) {
// COMPILE ERROR: cannot check parameterized type
// if (obj instanceof List<String>) { }
// OK: unbounded wildcard
if (obj instanceof List<?> list) {
System.out.println("It is a List of size " + list.size());
}
}
}
Why can you not create generic arrays?
Arrays are reified: they carry their component type and check it at runtime with store checks. This directly conflicts with erased generics.
package com.bank.service;
import java.util.List;
import java.util.ArrayList;
public class GenericArrayDemo {
public void arrayProblem() {
// COMPILE ERROR: cannot create generic array
// List<String>[] accountLists = new List<String>[10];
// Workaround: use List of Lists
List<List<String>> accountLists = new ArrayList<>();
// Or use Object[] with unchecked cast
@SuppressWarnings("unchecked")
List<String>[] arr = (List<String>[]) new List<?>[10];
}
}
Why can you not overload by generic type?
After erasure, methods that differ only by generic type argument have the same raw signature and clash.
package com.bank.service;
import java.util.List;
import java.math.BigDecimal;
public class OverloadClashDemo {
// COMPILE ERROR: both erase to process(List)
// public void process(List<String> accounts) { }
// public void process(List<BigDecimal> amounts) { }
// Fix: use different method names
public void processAccounts(List<String> accounts) {
System.out.println("Processing accounts");
}
public void processAmounts(List<BigDecimal> amounts) {
System.out.println("Processing amounts");
}
}
What is the PECS rule?
PECS stands for Producer Extends, Consumer Super. Use ? extends T when reading from a structure (producer) and ? super T when writing into it (consumer).
package com.bank.service;
import java.util.List;
import java.util.ArrayList;
import java.math.BigDecimal;
public class PecsDemo {
// PRODUCER: read accounts out (? extends Account)
public BigDecimal sumBalances(
List<? extends Account> accounts) {
BigDecimal total = BigDecimal.ZERO;
for (Account acc : accounts) {
total = total.add(acc.getBalance());
}
return total;
// Can read as Account
// Cannot add to list (except null)
}
// CONSUMER: write accounts in (? super SavingsAccount)
public void onboardNewAccounts(
List<? super SavingsAccount> target) {
target.add(new SavingsAccount("SAV2001",
new BigDecimal("50000.00")));
target.add(new SavingsAccount("SAV2002",
new BigDecimal("75000.00")));
// Can add SavingsAccount
// When reading, only get Object back
}
}
How does Collections.copy demonstrate PECS?
The canonical PECS example reads from an extends source and writes to a super destination.
import java.util.Collections;
import java.util.List;
import java.util.ArrayList;
// Signature: copy(List<? super T> dest, List<? extends T> src)
// src is PRODUCER (extends): we read from it
// dest is CONSUMER (super): we write into it
List<SavingsAccount> savings = List.of(
new SavingsAccount("SAV2001", new BigDecimal("50000.00")));
List<Account> allAccounts = new ArrayList<>(savings);
// SavingsAccount extends Account:
// src: List<? extends Account> ← List<SavingsAccount> OK
// dest: List<? super Account> could accept List<Object>
Collections.copy(allAccounts, savings); // Compiles
How is ? extends different from ? super? (Comparison)
| Feature | ? extends T (Producer) | ? super T (Consumer) |
|---|---|---|
| Reading | Safe: every element is at least T | Unsafe: only get Object back |
| Writing | Cannot add (except null) | Safe: can add T values |
| Use when | Structure is a source you read from | Structure is a sink you write into |
| Example | sumBalances(List extends Account>) | onboard(List super SavingsAccount>) |
| Mnemonic | PE: Producer Extends | CS: Consumer Super |
What are common mistakes with erasure and PECS?
Getting the wildcard wrong is one of the most common sources of confusing "cannot be applied" compile errors.
// WRONG: using extends when writing
public void addAccount(List<? extends Account> list) {
// list.add(new SavingsAccount(...)); // COMPILE ERROR!
// Cannot add to a producer
}
// CORRECT: use super when writing
public void addAccount(List<? super SavingsAccount> list) {
list.add(new SavingsAccount("SAV2001",
new BigDecimal("50000.00"))); // OK
}
// WRONG: thinking generics exist at runtime
if (list instanceof List<String>) { } // COMPILE ERROR
// CORRECT: use unbounded wildcard
if (list instanceof List<?>) { } // OK
What about unchecked cast warnings?
Erasure is why you see @SuppressWarnings("unchecked") on casts like (T) obj. The runtime cannot verify the cast, so the compiler warns you.
package com.bank.service;
public class UncheckedCastDemo {
@SuppressWarnings("unchecked")
public <T> T safeCast(Object obj, Class<T> type) {
if (type.isInstance(obj)) {
return (T) obj; // Unchecked, but safe with isInstance guard
}
throw new ClassCastException(
"Cannot cast to " + type.getName());
}
}
What should a senior engineer know?
Erasure is why generics are a compile-time feature with zero runtime cost. It is also why you sometimes pass a Class token or a TypeReference to recover type information at runtime, as Jackson and Spring do. PECS is not academic: getting the wildcard wrong is a very common cause of "cannot be applied" compile errors in real APIs. If you both read and write, use an exact T with no wildcard.
Visual Overview
┌───────────────────────────────────────────────────┐
│ Type Erasure Process │
├───────────────────────────────────────────────────┤
│ │
│ Source Code: │
│ List<String> accounts = new ArrayList<>(); │
│ accounts.add("SAV2001"); │
│ String id = accounts.get(0); │
│ │
│ After Erasure (Bytecode): │
│ List accounts = new ArrayList(); │
│ accounts.add("SAV2001"); │
│ String id = (String) accounts.get(0); // cast │
│ │
│ Runtime: List<String> == List<Integer> == List │
└───────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────┐
│ PECS: Producer Extends Consumer Super │
├───────────────────────────────────────────────────┤
│ │
│ ? extends T (PRODUCER) ? super T (CONSUMER)│
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Read: T ✓ │ │ Read: Object │ │
│ │ Write: ✗ │ │ Write: T ✓ │ │
│ │ │ │ │ │
│ │ List<? extends │ │ List<? super │ │
│ │ Account> │ │ SavingsAcct> │ │
│ │ ← read balances │ │ → add accounts │ │
│ └──────────────────┘ └──────────────────┘ │
└───────────────────────────────────────────────────┘
Key Takeaway
Type erasure is the compiler's process of using generic type information to check code at compile time and then removing it, replacing each type parameter with its bound or Object and inserting casts. The practical consequence is that at runtime a List
That one fact makes several things impossible. new T() fails because at runtime T is erased and the JVM does not know which constructor to call, so the workaround is passing a Class
PECS (Producer Extends, Consumer Super) is the rule for wildcard bounds. A List extends T> is a producer: you can safely read every element as T but cannot add. A List super T> is a consumer: you can safely add T values but when reading you only get Object back. If a parameter is a source you read from, use extends. If it is a sink you write into, use super. If you both read and write, use an exact T.
In banking work at National Bank of India a generic repository could not use new T(), so it took a Class token and created instances reflectively. A reporting utility summed balances over a List extends Account> so it could accept a list of SavingsAccount and read every element as Account. An onboarding utility added savings records into a List super SavingsAccount> so it could write into a list typed to Account.
Remember one sentence: type erasure removes generic types at runtime making new T() and instanceof on parameterized types impossible, and PECS says use extends to read and super to write.
---