Java Generics Deep Dive: Wildcards, Bounds, and Type Erasure
A comprehensive guide to Java Generics — covering bounded wildcards, PECS principle, type erasure gotchas, and practical patterns for writing reusable, type-safe APIs.
Generics were introduced in Java 5 to provide compile-time type safety without runtime overhead. After twenty years, they remain one of the most misunderstood — and most powerful — features in the language.
Why Generics Exist
Before generics, collections stored raw Object references, requiring explicit casting:
// Pre-generics Java — every get() call is a ClassCastException waiting to happen
List names = new ArrayList();
names.add("Alice");
String name = (String) names.get(0); // OK
names.add(42); // Compiles fine, explodes at runtime
With generics:
List<String> names = new ArrayList<>();
names.add("Alice");
names.add(42); // ✗ Compile-time error: incompatible types
String name = names.get(0); // No cast needed
Java Generics Type Hierarchy
The diagram below shows how Java’s Number hierarchy maps to wildcard rules — the foundation of the PECS principle.
Bounded Type Parameters
Upper Bound (extends)
Use <T extends SomeType> when you need to read from a structure — you know everything in the collection is at least a Number.
// Sum any list of numbers: Integer, Double, Long, BigDecimal — all work
public static double sum(List<? extends Number> numbers) {
double total = 0;
for (Number n : numbers) {
total += n.doubleValue();
}
return total;
}
// Usage
List<Integer> ints = List.of(1, 2, 3);
List<Double> dbs = List.of(1.1, 2.2, 3.3);
System.out.println(sum(ints)); // 6.0
System.out.println(sum(dbs)); // 6.6
Lower Bound (super)
Use <T super SomeType> when you need to write into a structure.
// Add integers into any list capable of holding them
public static void addNumbers(List<? super Integer> list) {
for (int i = 1; i <= 5; i++) {
list.add(i);
}
}
List<Number> numbers = new ArrayList<>();
addNumbers(numbers); // Works — Number is a supertype of Integer
The PECS Principle
Producer Extends, Consumer Super
// Producer: we READ from source → extends
public static <T> void copy(List<? extends T> source, List<? super T> dest) {
for (T item : source) {
dest.add(item);
}
}
Type Erasure
Java generics are erased at compile time. The JVM only sees raw types at runtime.
List<String> strings = new ArrayList<>();
List<Integer> ints = new ArrayList<>();
// Both have the same runtime class!
System.out.println(strings.getClass() == ints.getClass()); // true
// This will NOT compile — cannot overload on generic type alone
// public void process(List<String> l) {}
// public void process(List<Integer> l) {} // ✗ Erasure conflict
Practical Implications
// ✗ Cannot create generic arrays
T[] arr = new T[10]; // Compile error
// ✓ Use ArrayList instead
List<T> list = new ArrayList<>();
// ✗ Cannot use instanceof with parameterized types
if (obj instanceof List<String>) {} // Compile error
// ✓ Use raw type with instanceof, then cast carefully
if (obj instanceof List<?> rawList) {
// Safe unchecked cast (document with @SuppressWarnings)
@SuppressWarnings("unchecked")
List<String> strings = (List<String>) rawList;
}
Generic Methods
// A generic utility that works on any Comparable type
public static <T extends Comparable<T>> T max(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}
System.out.println(max(10, 20)); // 20
System.out.println(max("apple", "zen")); // zen
Summary Table
| Wildcard | Read | Write | Use case |
|---|---|---|---|
<T> | ✓ | ✓ | Generic methods with full control |
<? extends T> | ✓ | ✗ | Read-only producers (PECS: Producer) |
<? super T> | ✗ (Object) | ✓ | Write-only consumers (PECS: Consumer) |
<?> | ✓ (Object) | ✗ | Unknown type — read only as Object |
Mastering generics unlocks the ability to write reusable, type-safe libraries that compose elegantly across the Java ecosystem.