Introduction
If you've ever tried to write a flexible generic method that works with subtypes of a class, you've probably hit a wall: Java's type system seems to reject perfectly reasonable code. The solution lies in understanding PECS — Producer Extends, Consumer Super — a principle that governs how to use wildcards in generic APIs. This guide walks you through the exact rules, shows you when and where to apply them, and explains why they exist. By the end, you'll know how to write generic code that's both type-safe and genuinely reusable.
The problem: variance in generics
Java generics are invariant by default. That means List<Integer> is not a subtype of List<Number>, even though Integer is a subtype of Number. This protects you:
List<Integer> ints = new ArrayList<>();
List<Number> nums = ints; // Compile error
nums.add(3.14); // If this were allowed, we'd put a Double in an Integer list
Without that restriction, we'd corrupt the original list. But it also makes generics frustratingly rigid. You can't pass List<Integer> to a method expecting List<Number>, even if you only plan to read from it.
Wildcards solve this by letting us declare what we actually intend to do with the generic type.
List<? extends Number> — you can read Numbers
The upper-bounded wildcard ? extends Number means "some type that is Number or a subclass of Number." When you use this in a parameter, you're saying: "I promise I will only read from this container."
public void printNumbers(List<? extends Number> numbers) {
for (Number num : numbers) {
System.out.println(num);
}
}
// Now we can call it with any numeric list
List<Integer> ints = Arrays.asList(1, 2, 3);
List<Double> doubles = Arrays.asList(1.5, 2.5);
printNumbers(ints);
printNumbers(doubles);
Why does this work? The compiler knows that every element in the list is at least a Number, so you can safely read it as a Number. The type parameter is guaranteed by the caller, not invented by you.
The trade-off: you cannot add to the list (except null). Why not?
public void addToList(List<? extends Number> numbers) {
numbers.add(42); // Compile error
}
If the caller passed List<Integer>, you can't add a Long. If they passed List<Double>, you can't add an Integer. The compiler can't know what specific type the list holds, so it forbids all writes. This is the producer pattern: the list is producing values for you to consume.
List<? super Integer> — you can add Integers
The lower-bounded wildcard ? super Integer means "some type that is Integer or a superclass of Integer." This is for writing. You're saying: "I promise I will only write compatible values to this container."
public void addIntegers(List<? super Integer> numbers) {
numbers.add(1);
numbers.add(2);
numbers.add(3);
}
// Works with Integer or Number lists
List<Integer> ints = new ArrayList<>();
List<Number> numsBase = new ArrayList<>();
addIntegers(ints);
addIntegers(numsBase);
The logic: if the list accepts Integer or any superclass of it, then it definitely accepts Integer values. You're safe to write.
The trade-off: what you read back is only guaranteed to be Object.
public void addIntegers(List<? super Integer> numbers) {
Object o = numbers.get(0); // Safe
Integer i = numbers.get(0); // Compile error
Number n = numbers.get(0); // Compile error
}
If the caller passed List<Number>, they could have stored a Double in position 0 before calling us. We can't assume it's an Integer. This is the consumer pattern: you're consuming (writing) values to the list, and the list decides how to store them.
PECS in action: real-world patterns
Let's see how PECS guides design in realistic scenarios.
Building a utility for processing collections
// Copy elements from source to destination
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (T item : src) {
dest.add(item);
}
}
List<Integer> ints = Arrays.asList(1, 2, 3);
List<Number> numbers = new ArrayList<>();
copy(numbers, ints); // Works: we read Integers, write Numbers
Notice the pattern: the source is ? extends T (producer, read-only) and the destination is ? super T (consumer, write-only). This mirrors Java's own Collections.copy().
Filtering and collecting
public static <T> void filterInto(
List<? extends T> source,
List<? super T> dest,
Predicate<? super T> filter
) {
for (T item : source) {
if (filter.test(item)) {
dest.add(item);
}
}
}
List<Integer> ints = Arrays.asList(1, 2, 3, 4, 5);
List<Number> evens = new ArrayList<>();
filterInto(ints, evens, n -> n % 2 == 0);
Even the predicate is ? super T: it's okay if the predicate accepts a broader type (e.g., Number), as long as it handles our T.
Use wildcards in parameters, not return types
A critical rule: never use wildcards in return types. The caller can't work with the result.
// Bad
public List<? extends Number> getNumbers() {
return new ArrayList<>();
}
List<? extends Number> result = getNumbers();
// Now what? You can't iterate and do anything useful
Number n = result.get(0); // OK
result.add(42); // Error
Instead, return a concrete type or a type parameter:
// Good
public <T extends Number> List<T> getNumbers(Class<T> type) {
List<T> list = new ArrayList<>();
return list;
}
List<Integer> result = getNumbers(Integer.class);
result.add(42); // Works
Use wildcards in method parameters to accept flexible inputs. Keep return types concrete or use type parameters so callers know what they're getting back.
Type erasure: no new T() at runtime
Java's generics are implemented via type erasure: all generic type information is stripped at runtime. The JVM sees List<Integer> as just List. This has real consequences for what you can and cannot do.
You cannot instantiate generic types
public <T> T create() {
return new T(); // Compile error: no T.class at runtime
}
The JVM doesn't know what T is. You can't call constructors on it. Workarounds include using a Supplier<T> or passing Class<T>:
public <T> T create(Supplier<T> factory) {
return factory.get();
}
public <T> T create(Class<T> clazz) throws Exception {
return clazz.getDeclaredConstructor().newInstance();
}
String str = create(String::new);
Integer num = create(Integer.class);
You cannot cast to parameterized types
Object obj = new ArrayList<String>();
List<String> strings = (List<String>) obj; // Unchecked warning
The compiler warns: at runtime, it's just a cast to List. You're risking a ClassCastException if you later do something type-unsafe. Use raw types or instanceof checks carefully:
if (obj instanceof List) {
List<?> list = (List<?>) obj;
// Safe: we use wildcard to avoid lies
}
Type parameters disappear; wildcards don't (in signatures)
Method signatures preserve wildcard information for reflection, but the actual type arguments do not:
public void process(List<? extends Number> nums) { }
// At runtime, reflection sees the ? extends Number wildcard
public void transform(List<Integer> ints) { }
// At runtime, reflection sees this is a List, not List<Integer>
Common mistakes
Forgetting variance entirely
// Wrong: overly restrictive
public void process(List<Number> nums) {
for (Number n : nums) {
System.out.println(n);
}
}
List<Integer> ints = Arrays.asList(1, 2, 3);
process(ints); // Error: won't accept List<Integer>
// Right
public void process(List<? extends Number> nums) {
for (Number n : nums) {
System.out.println(n);
}
}
process(ints); // Works
Using extends when you need super
// Wrong: you want to add, but declared extends
public void addValues(List<? extends Integer> list) {
list.add(42); // Compile error
}
// Right
public void addValues(List<? super Integer> list) {
list.add(42); // OK
}
Wildcards in return types
// Awkward
public List<? extends Number> getData() {
return new ArrayList<>();
}
var result = getData();
// result is List<? extends Number>, very hard to use
// Better
public List<Number> getData() {
return new ArrayList<>();
}
// or use a type parameter
public <T extends Number> List<T> getData(Class<T> type) { ... }
Assuming you can store generic type info at runtime
// Doesn't work as you might hope
List<String> strings = new ArrayList<>();
List<Integer> ints = new ArrayList<>();
strings.getClass() == ints.getClass(); // true! Both are ArrayList
// Generic type info is gone at runtime
If you need to preserve type info, use type tokens or pass Class<T> explicitly.
Key takeaways
- PECS stands for Producer Extends, Consumer Super. Use
? extends Twhen you're reading (producing values for your code), and? super Twhen you're writing (consuming values). - Upper-bounded wildcards (
? extends Number) let you read values as their upper bound type, but you can't write anything exceptnull. - Lower-bounded wildcards (
? super Integer) let you write compatible values, but you can only read backObject. - Keep wildcards in method parameters, not return types. Callers need concrete types or type parameters to know what they're getting back.
- Type erasure removes generic type information at runtime. You can't instantiate
T, cast to parameterized types safely, or rely on generic type info in reflection without workarounds likeClass<T>or type tokens. - Wildcards in method signatures are preserved, but type arguments (e.g.,
List<Integer>) are not. Use reflection carefully with generics. - Apply PECS consistently across parameter lists. If you copy from source to destination, make source
? extends Tand destination? super T, just like the JDK does.






Comments
Loading comments…