Lesson 20 • Advanced
Advanced Generics: Wildcards & Type Erasure
Move past Box<T> basics. By the end you'll design bounded generic methods, read wildcard signatures fluently with PECS, and know exactly what type erasure takes away — and how to work around it.
What You'll Learn in This Lesson
- ✓You'll be able to write bounded type parameters with <T extends ...>
- ✓You'll be able to use recursive bounds like <T extends Comparable<T>>
- ✓You'll be able to apply PECS to choose ? extends vs ? super correctly
- ✓You'll be able to let the compiler infer type arguments for generic methods
- ✓You'll be able to explain type erasure and its runtime consequences
- ✓You'll be able to fix erasure limits with Class<T> tokens and @SafeVarargs
📚 Before You Start
This lesson builds on a few things you've already met. You should be comfortable with:
- Generics basics —
Box<T>, type parameters, and simple bounds (Generics lesson) - Collections — using
ArrayListandHashMap(Collections lesson) - Inheritance — subtypes, supertypes, and polymorphism (Inheritance lesson)
🍎 A Real-World Analogy: Delivery Boxes
Picture three kinds of boxes arriving at a warehouse, and you'll never confuse the wildcards again.
List<? extends Fruit>
A read-only fruit box. You can take items out (each one is at least a Fruit), but you can't put anything in — nobody told you if it's an apple box or an orange box.
List<? super Apple>
A write-only apple-friendly box. You can drop Apples in, but when you read items back all you know is they're Object.
List<Apple>
A plain apple box. You can both add Apples and read Apples. Full access — but it only accepts that one exact type.
The whole lesson is just learning which box to ask for. The rule that decides is PECS, which you'll meet in Section 2.
1️⃣ Bounded Type Parameters (and Recursive Bounds)
A plain <T> means "any type at all" — which is safe, but you can only call Object methods on it. A bound restricts what T can be so you can call more useful methods. You write it with extends:
static <T extends Number> double sumAll(List<T> items) {
double total = 0;
for (T n : items) total += n.doubleValue(); // legal because T IS-A Number
return total;
}Read <T extends Number> as "T can be Number or any subtype of it" (so Integer, Double, …). Note Java always uses extends here — even for interfaces. <T extends Comparable> is fine even though Comparable is an interface you'd normally implement.
Recursive bounds: <T extends Comparable<T>>
To compare two values you need them to be Comparable. But Comparable of what? You want "comparable to its own type". That's a recursive bound — the type parameter appears inside its own bound:
static <T extends Comparable<T>> T maxOf(T a, T b) {
return a.compareTo(b) >= 0 ? a : b; // a and b are guaranteed comparable
}<T> matters: without it you'd accept a type that compares against something unrelated, and a.compareTo(b) would not be type-safe. The recursive bound is exactly what Collections.max and Collections.sort use.import java.util.List;
public class Main {
// BOUNDED type parameter: T must be a Number (or a subtype of it).
// Inside the method you may call any Number method, e.g. doubleValue().
static <T extends Number> double sumAll(List<T> items) {
double total = 0;
for (T n : items) total += n.doubleValue(); // legal: T IS-A Number
return total;
}
// RECURSIVE bound: "T must be comparable to itself".
// This guarantees a.compareTo(b) is type-safe for any T we pass in.
static <T extends Comparable<T>> T maxOf(T a, T b) {
return a.compareTo(b) >= 0 ? a : b; // returns the larger one
}
public static void main(String[] args) {
// sumAll accepts any List of Number subtypes
System.out.println("sum ints: " + sumAll(List.of(1, 2, 3))); // 6.0
System.out.println("sum doubles: " + sumAll(List.of(1.5, 2.5))); // 4.0
// maxOf works for anything Comparable: Integer, String, ...
System.out.println("max int: " + maxOf(7, 3)); // 7
System.out.println("max string: " + maxOf("apple", "banana")); // banana
}
}sum ints: 6.0
sum doubles: 4.0
max int: 7
max string: banana2️⃣ Wildcards & the PECS Rule
A wildcard is the ? you sometimes see inside angle brackets. It means "some specific type, but I'm not naming it". The two bounded forms are the ones that matter:
? extends T— "T or any subtype". You can read asT, but cannot add.? super T— "T or any supertype". You can add aT, but reads only giveObject.?— "any type at all". Read-only, asObject.
🧠 PECS — the golden rule: Producer Extends, Consumer Super. If a parameter produces values you read out, use ? extends. If it consumes values you write in, use ? super. If it does both, use a plain named type.
// PRODUCER — only reads, so ? extends
double sum(List<? extends Number> nums) { ... } // List<Integer>, List<Double>...
// CONSUMER — only writes, so ? super
void fill(List<? super Integer> sink) { sink.add(1); } // List<Integer>, List<Number>, List<Object>The JDK's own Collections.copy(List<? super T> dest, List<? extends T> src) is the canonical example: src produces (extends), dest consumes (super).
3️⃣ Type Inference — Let the Compiler Do the Work
You rarely have to spell out type arguments. The compiler infers them from the values you pass and from what you assign the result to.
// Diamond <> infers the right-hand type from the left (Java 7+)
List<String> names = new ArrayList<>(); // infers ArrayList<String>
// Generic method: T is inferred from the arguments
static <T extends Comparable<T>> T maxOf(T a, T b) { ... }
int m = maxOf(3, 7); // T inferred as Integer -> 7You can name the type explicitly with a "witness" — Main.<Integer>maxOf(3, 7) — but you almost never need to. Inference keeps generic code readable.
import java.util.ArrayList;
import java.util.List;
public class Main {
// PRODUCER: the list gives values OUT, so use ? extends Number ("extends").
// You can read a Number from it; you may NOT add to it.
static double sum(List<? extends Number> producer) {
double total = 0;
for (Number n : producer) total += n.doubleValue();
return total;
}
// CONSUMER: the list takes values IN, so use ? super Integer ("super").
// You can add Integers; reading back only guarantees Object.
static void fill(List<? super Integer> consumer, int from, int to) {
for (int i = from; i <= to; i++) consumer.add(i);
}
// PECS in one method: copy FROM a producer INTO a consumer.
static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (T item : src) dest.add(item);
}
public static void main(String[] args) {
// sum reads from any List of Number subtypes (producer)
System.out.println("sum(ints): " + sum(List.of(1, 2, 3))); // 6.0
System.out.println("sum(doubles): " + sum(List.of(1.5, 2.5))); // 4.0
// fill writes Integers into a List<Number> (consumer)
List<Number> nums = new ArrayList<>();
fill(nums, 1, 4);
System.out.println("after fill: " + nums); // [1, 2, 3, 4]
// copy uses BOTH: T is inferred as Integer from the arguments
List<Integer> src = List.of(10, 20, 30);
List<Object> dst = new ArrayList<>(List.of("seed"));
copy(dst, src);
System.out.println("after copy: " + dst); // [seed, 10, 20, 30]
}
}sum(ints): 6.0
sum(doubles): 4.0
after fill: [1, 2, 3, 4]
after copy: [seed, 10, 20, 30]4️⃣ Type Erasure & Its Consequences
Generics are a compile-time feature. After the compiler has finished checking your types, it erases them — List<String> and List<Integer> both become plain List at runtime, sharing one class object.
That erasure buys backward compatibility with old Java, but it takes a few things away. None of these compile:
❌ Can't do at runtime:
new T() // no constructor for an erased type new T[10] // generic array creation if (x instanceof List<String>) // can't test type args
✅ The workarounds:
Class<T> token // pass the type explicitly token.cast(x) // erasure-safe cast token.isInstance(x) // erasure-safe check
The Class<T> type token
Since the type is gone at runtime, you hand it back in as data: pass String.class (a Class<String>) and use token.cast(...) / token.isInstance(...). For nested generics like List<String> — which .class can't express — the trick is a super type token: an anonymous subclass of a generic base class whose type argument you read back via reflection (the pattern Jackson's TypeReference and Guice's TypeLiteral use).
Heap pollution & @SafeVarargs
Heap pollution is when a variable of a generic type secretly points at the wrong type. Generic varargs (T... args) create a hidden array whose element type is erased, so the compiler warns. If your method only reads that array and never stores a bad value into it, the warning is a false alarm — annotate the method with @SafeVarargs to promise that and silence the warning at the declaration.
import java.util.ArrayList;
import java.util.List;
public class Main {
// PROBLEM: you cannot write "new T()" or "new T[n]" — T is erased at runtime.
// WORKAROUND: pass a Class<T> "type token" so the runtime knows the real type.
static <T> T firstOrNull(List<?> list, Class<T> type) {
if (list.isEmpty()) return null;
Object first = list.get(0);
// type.isInstance(...) is the erasure-safe way to check the type
return type.isInstance(first) ? type.cast(first) : null;
}
// Generic varargs cause "heap pollution" warnings. @SafeVarargs says
// "I only READ the array, I never store an incompatible value into it."
@SafeVarargs
static <T> List<T> listOf(T... items) {
return new ArrayList<>(List.of(items)); // safe: we only read 'items'
}
public static void main(String[] args) {
// List<String> and List<Integer> share ONE runtime class (erasure):
boolean sameClass = List.of("a").getClass() == List.of(1).getClass();
System.out.println("same runtime class? " + sameClass); // true
// Class<T> token lets us recover type safety at runtime:
List<String> words = List.of("hello", "world");
String w = firstOrNull(words, String.class);
System.out.println("firstOrNull: " + w); // hello
// @SafeVarargs builder, type inferred as Integer:
System.out.println("listOf: " + listOf(1, 2, 3)); // [1, 2, 3]
}
}same runtime class? true
firstOrNull: hello
listOf: [1, 2, 3]🎯 Your Turn #1 — Bounds & Recursive Bounds
.doubleValue(); the second makes compareTo type-safe. Run it and check your output against the comment at the bottom.import java.util.List;
public class Main {
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) Bound T so you can call .doubleValue() on each element.
// 👉 replace ___ with the bound: extends Number
static <T ___> double average(List<T> items) {
double total = 0;
for (T n : items) total += n.doubleValue();
return total / items.size();
}
// 2) Write a recursive bound so a.compareTo(b) is type-safe.
// 👉 replace ___ with: extends Comparable<T>
static <T ___> T smaller(T a, T b) {
return a.compareTo(b) <= 0 ? a : b; // return the SMALLER one
}
public static void main(String[] args) {
System.out.println(average(List.of(2, 4, 6))); // 4.0
System.out.println(smaller("pear", "apple")); // apple
}
// ✅ Expected output:
// 4.0
// apple
}🎯 Your Turn #2 — Pick the Right Wildcard
import java.util.ArrayList;
import java.util.List;
public class Main {
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) This method only READS from the list (a producer).
// 👉 replace ___ with the producer wildcard: ? extends Number
static double total(List<___> producer) {
double sum = 0;
for (Number n : producer) sum += n.doubleValue();
return sum;
}
// 2) This method only WRITES to the list (a consumer).
// 👉 replace ___ with the consumer wildcard: ? super Integer
static void addOneToFive(List<___> consumer) {
for (int i = 1; i <= 5; i++) consumer.add(i);
}
public static void main(String[] args) {
System.out.println("total: " + total(List.of(1.0, 2.0, 3.0))); // total: 6.0
List<Number> sink = new ArrayList<>();
addOneToFive(sink);
System.out.println("filled: " + sink); // filled: [1, 2, 3, 4, 5]
}
// ✅ Expected output:
// total: 6.0
// filled: [1, 2, 3, 4, 5]
}🏆 Mini-Challenge — Write a Generic max() (Support Faded)
maxOf method yourself from the comments. If you get stuck, peek back at Worked Example 1.import java.util.List;
public class Main {
// 🎯 MINI-CHALLENGE: a generic, null-safe "max" finder
//
// 1. Write a generic method <T extends Comparable<T>> T maxOf(List<T> list)
// that returns the largest element in the list.
// 2. Start "max" as list.get(0), then loop and keep the bigger element
// using item.compareTo(max) > 0.
// 3. In main(), call it on List.of(3, 9, 2, 7) and on List.of("kiwi","fig","pear").
//
// ✅ Expected output:
// 9
// pear
// your code here
}Common Errors & Fixes
- ❌
The method add(...) is not applicable for the type capture of ? extends Number— you tried to write into a? extends(producer) list. Producers are read-only. Use? superif you need to add. - ❌
incompatible types: List<Dog> cannot be converted to List<Animal>— generics are invariant. AcceptList<? extends Animal>(to read) orList<? super Dog>(to write) instead. - ❌
generic array creation—new T[10]andnew List<String>[10]are illegal because of erasure. Use aList<T>(or anObject[]cast you control) instead. - ❌
illegal generic type for instanceof—x instanceof List<String>can't work after erasure. Testx instanceof List<?>, or carry aClass<T>token and calltoken.isInstance(x). - ❌
Possible heap pollution from parameterized vararg type— a genericT...parameter. If the method only reads the array, add@SafeVarargsto the method to confirm it's safe and clear the warning.
📋 Quick Reference
| Syntax | Meaning | Read / Write |
|---|---|---|
| <T extends Number> | Named param bounded to Number subtypes | Full access to T |
| <T extends Comparable<T>> | Recursive bound: T comparable to itself | Full access to T |
| ? extends T | Producer: T or a subtype | Read ✅ Write ❌ |
| ? super T | Consumer: T or a supertype | Read as Object Write ✅ |
| ? | Unbounded: any type | Read as Object only |
| Class<T> | Type token (beats erasure) | cast / isInstance |
| @SafeVarargs | Promise: generic varargs are read-only | Silences heap-pollution warning |
❓ Frequently Asked Questions
What is the difference between <T extends Number> and <? extends Number>?
<T extends Number> names a type parameter you can reuse — every element is the same concrete type T, and you can return T or relate arguments to each other. <? extends Number> is a wildcard with no name: it means 'some unknown subtype of Number', so you can read elements as Number but cannot add anything (you don't know the exact type). Use the named form on methods where the type must line up across arguments or the return value; use the wildcard when the method only needs to read.
What does PECS mean and how do I remember it?
PECS stands for Producer Extends, Consumer Super. If a parameter produces values you read OUT of it, declare it with ? extends T. If it consumes values you write IN to it, declare it with ? super T. Java's own Collections.copy(List<? super T> dest, List<? extends T> src) is the canonical example: src produces (extends), dest consumes (super).
Why does <T extends Comparable<T>> have T inside it twice?
That is a recursive (self-referential) bound. It says 'T must be comparable to its own type'. Without the inner <T>, you would accept any Comparable, even one that compares against an unrelated type, and a.compareTo(b) would not be type-safe. The recursive bound is exactly what Collections.max and Collections.sort use so the compiler can guarantee every comparison is between two T values.
What is type erasure and what can't I do because of it?
At compile time Java checks generics strictly, but at runtime it erases the type arguments — List<String> and List<Integer> share one runtime class, List. Because of erasure you cannot write new T(), new T[n], or instanceof List<String>. The standard workarounds are to pass a Class<T> 'type token' (then use type.cast / type.isInstance), or for nested generics a 'super type token' built from an anonymous subclass of a TypeReference-style base.
What is heap pollution and why does @SafeVarargs exist?
Heap pollution happens when a variable of a parameterized type points to an object that isn't of that type — generic varargs (T... args) create a hidden array whose element type isn't fully known, so the compiler warns. If your method only READS from that array and never stores a wrong-typed value into it, the warning is a false alarm; annotating the method with @SafeVarargs documents that promise and suppresses the warning at the declaration instead of at every call site.
Why can't I cast List<Dog> to List<Animal> even though Dog is an Animal?
Generics are invariant: List<Dog> is NOT a subtype of List<Animal>, even though Dog is a subtype of Animal. If the cast were allowed you could add a Cat to a List<Animal> that is really a List<Dog>, breaking type safety. To get covariance for reading, use List<? extends Animal>; for writing, use List<? super Dog>.
🎉 Lesson Complete!
Excellent work. You can now read and write the generic signatures that scared you off before: bounded parameters, recursive bounds, and the ? extends / ? super wildcards that PECS tells you to pick. You also know what type erasure removes at runtime and how Class<T> tokens, super type tokens, and @SafeVarargs get you around it.
Next up: Streams API — combine filter, map, collect, and flatMap into clean data pipelines, leaning on the generics fluency you just built.
Sign up for free to track which lessons you've completed and get learning reminders.