Java Generics: Wildcards and Bounds
Understand Java generics deeply: type parameters, upper and lower bounds, the PECS rule, and why type erasure shapes the rules you have to follow.
What you'll learn
- ✓How generic type parameters are declared and used
- ✓The meaning of ? extends T and ? super T
- ✓Why PECS (Producer Extends, Consumer Super) works
- ✓How type erasure constrains generic code
- ✓When to use bounded type parameters vs wildcards
Prerequisites
- •Familiarity with Java classes and collections
What and Why
Generics let you write code that works with many types without sacrificing static type checking. They are the reason List<String> rejects an Integer at compile time rather than failing at runtime. The tricky parts are wildcards (?, ? extends T, ? super T) and the rules that follow from type erasure.
Once these click, library APIs you have used for years suddenly make sense.
Mental Model
A generic class declares a type parameter that callers fill in. A bounded type parameter constrains it. A wildcard is a value-side concept: it describes the unknown type a particular variable holds at a particular call site.
List<Number> exact type Number
List<? extends Number> any subtype of Number (covariant; read-only)
List<? super Integer> any supertype of Integer (contravariant; write-only)
List<?> some unknown type (read as Object only) The mnemonic is PECS: Producer Extends, Consumer Super. If a parameter produces values you read, use ? extends T. If it consumes values you write, use ? super T.
Hands-on Example
Here is a small utility that copies elements from one list to another using PECS.
import java.util.*;
public class Generics {
// src produces Ts; dst consumes Ts
public static <T> void copy(List<? extends T> src, List<? super T> dst) {
for (T item : src) dst.add(item);
}
// Bounded type parameter: every T must be Comparable to itself
public static <T extends Comparable<T>> T max(Collection<? extends T> items) {
Iterator<? extends T> it = items.iterator();
T best = it.next();
while (it.hasNext()) {
T cur = it.next();
if (cur.compareTo(best) > 0) best = cur;
}
return best;
}
public static void main(String[] args) {
List<Integer> ints = List.of(1, 2, 3);
List<Number> nums = new ArrayList<>();
copy(ints, nums); // works: Integer extends Number, Number is super of Integer
System.out.println(max(ints)); // 3
System.out.println(max(List.of("a","z","m"))); // z
}
}
Notice three things. copy accepts an Integer source even though the destination is Number. max works on any list whose element type knows how to compare to itself. And neither method needs casts.
Common Pitfalls
- Trying to add to
List<? extends T>: the compiler refuses, because the actual element type might be a subclass of T and your new T might not fit. The list is effectively read-only for new elements. - Reading specific types from
List<? super T>: you can only treat reads asObject, because the element type might be any supertype. new T(): type parameters are erased at runtime. You cannot instantiate them directly. Pass aSupplier<T>orClass<T>instead.- Arrays of generics:
new List<String>[10]is illegal because of the interaction between erasure and runtime array type checks. UseList<List<String>>instead. - Confusing
List<Object>andList<?>: aList<Object>accepts any Object as a parameter, but is not a supertype ofList<String>. AList<?>is.
Practical Tips
Use a bounded type parameter (<T extends Number>) when multiple parameters or the return type need to refer to the same unknown type. Use wildcards when only one parameter mentions the type and you want maximum flexibility for callers.
When writing a public API method, ask: does this parameter produce values, consume them, or both? Both means you probably want an exact type parameter, not a wildcard.
Embrace inferred types with var and the diamond operator (new ArrayList<>()). They reduce noise without weakening static checks.
For factories that need the runtime class, accept a Class<T> token:
public static <T> T parse(String json, Class<T> type) { /* ... */ }
This works around erasure cleanly.
When debugging strange type errors, try writing the call with explicit type witnesses: Collections.<Number>emptyList(). This often points at the inference path the compiler tried to take.
Wrap-up
Generics turn the compiler into your collaborator. Wildcards extend that collaboration to API design: they let callers pass a wider range of types while preserving safety. Internalize PECS, remember that erasure makes the compiler the only source of generic type information at runtime, and lean on bounded type parameters for symmetric cases. With these tools, you can read and write library code without flinching.
Related articles
- Java Java Generics: Type Parameters Done Right
Write Java generics that compile and read well. Type parameters, bounded wildcards, PECS, type erasure, and how to avoid raw types in your own code.
- Java Java Collections Framework Cheatsheet
A pragmatic tour of Java's collection interfaces and implementations, with guidance on choosing between List, Set, Map, and Queue variants in real applications.
- Java Java Concurrency with CompletableFuture
How CompletableFuture composes asynchronous work in Java: chaining, combining, error handling, executors, and the patterns that keep concurrent code readable.
- Java Java Exception Handling Best Practices
Clear rules for using checked and unchecked exceptions in Java, with patterns for wrapping, logging, and propagating errors in real production code.