Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 013 / 100

Java generics and wildcards: PECS — a step-by-step guide

Producer extends, consumer super — the rule that makes generic APIs flexible.

Expona Admin9 min read

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:

java
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."

java
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?

java
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."

java
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.

java
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

java
// 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

java
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.

java
// 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:

java
// 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

java
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>:

java
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

java
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:

java
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:

java
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

java
// 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

java
// 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

java
// 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

java
// 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 T when you're reading (producing values for your code), and ? super T when 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 except null.
  • Lower-bounded wildcards (? super Integer) let you write compatible values, but you can only read back Object.
  • 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 like Class<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 T and destination ? super T, just like the JDK does.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles