Skip to content
  • Wildcards, PECS & Type Erasure · Java

Wildcards, PECS & Type Erasure

LearnViaNotes @learnvianotes· 5 min read

List<Integer> is not a List<Number>. Wildcards (?, ? extends, ? super) restore flexibility, PECS tells you which to use, and type erasure explains generics' odd limitations.

Generics are invariant

Even though Integer is a Number, List<Integer> is not a subtype of List<Number>. If it were, you could add a Double to a list of integers through the List<Number> reference.

Java
import java.util.*;

public class Main {
    public static void main(String[] args) {
        List<Integer> ints = new ArrayList<>();
        List<Number> nums = ints;          // not allowed
        nums.add(3.14);
    }
}
Compiler error
Main.java:6: error: incompatible types: List<Integer> cannot be converted to List<Number>
        List<Number> nums = ints;          // not allowed
                            ^

Arrays are covariant (Integer[] is an Object[]), so the same mistake compiles and only fails at runtime:

Java
public class Main {
    public static void main(String[] args) {
        Object[] objs = new Integer[2];      // compiles: arrays are covariant
        objs[0] = 1;
        try {
            objs[1] = "text";                // runtime type check
        } catch (ArrayStoreException e) {
            System.out.println("ArrayStoreException: " + e.getMessage());
        }
    }
}
Output
ArrayStoreException: java.lang.String

The three wildcards

WildcardAcceptsRead asCan add?
List<?>A list of any typeObjectOnly null
List<? extends Number>List<Number>, List<Integer>, List<Double>…NumberNo (except null)
List<? super Integer>List<Integer>, List<Number>, List<Object>ObjectYes, Integers
Java
import java.util.*;

public class Main {
    static double sum(List<? extends Number> nums) {    // producer: we only read
        double s = 0;
        for (Number n : nums) s += n.doubleValue();
        return s;
    }

    static void fill(List<? super Integer> sink, int n) { // consumer: we only write
        for (int i = 1; i <= n; i++) sink.add(i);
    }

    static int count(Collection<?> anything) { return anything.size(); }

    public static void main(String[] args) {
        System.out.println(sum(List.of(1, 2, 3)));         // List<Integer>
        System.out.println(sum(List.of(1.5, 2.5)));        // List<Double>

        List<Number> numbers = new ArrayList<>();
        List<Object> objects = new ArrayList<>();
        fill(numbers, 3);
        fill(objects, 2);
        System.out.println(numbers + " " + objects);
        System.out.println(count(Set.of("a", "b")));
    }
}
Output
6.0
4.0
[1, 2, 3] [1, 2]
2

PECS: Producer Extends, Consumer Super

If a parameter produces Ts for you → ? extends T. If it consumes Ts → ? super T. Both → plain T.

Java
import java.util.*;

public class Main {
    // src produces T (extends), dest consumes T (super) — same shape as Collections.copy
    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) {
        List<Integer> ints = List.of(1, 2);
        List<Double> doubles = List.of(3.5);
        List<Number> all = new ArrayList<>();
        copy(all, ints);                      // T = Integer, Number is a super of it
        copy(all, doubles);                   // T = Double
        System.out.println(all);

        List<Object> objs = new ArrayList<>(List.of("x"));
        Main.<Integer>copy(objs, ints);
        System.out.println(objs);
    }
}
Output
[1, 2, 3.5]
[x, 1, 2]

JDK examples: Collections.copy(List<? super T> dest, List<? extends T> src), addAll(Collection<? extends E>), sort(Comparator<? super E>) — a Comparator<Object> can sort a list of strings.

Type erasure

Java
import java.util.*;

public class Main {
    public static void main(String[] args) {
        List<String> a = new ArrayList<>();
        List<Integer> b = new ArrayList<>();
        System.out.println(a.getClass() == b.getClass());
        System.out.println(a.getClass().getName());
        Object o = a;
        System.out.println(o instanceof List<?>);      // OK: unbounded wildcard
        // o instanceof List<String>                   // error: can't check a type argument at runtime
    }
}
Output
true
java.util.ArrayList
true
You can't…Because at runtime T is erasedWorkaround
new T()The JVM doesn't know what T isPass a Supplier<T> or Class<T>
new T[n], new List<String>[n]Arrays need their exact element type at runtimeUse a List<T>, or (T[]) new Object[n] with care
x instanceof List<String>Type arguments aren't storedinstanceof List<?>
Overload f(List<String>) and f(List<Integer>)Both erase to f(List) — same signatureUse different method names
catch (T e) / generic exception classesCatch needs a runtime type—
Use primitives as type argumentsErasure turns T into ObjectBoxed types, or specialised streams (IntStream)
Java
import java.util.*;
import java.util.function.Supplier;

public class Main {
    static <T> List<T> nCopies(int n, Supplier<T> factory) {    // instead of new T()
        List<T> out = new ArrayList<>();
        for (int i = 0; i < n; i++) out.add(factory.get());
        return out;
    }

    static <T> T create(Class<T> type) throws ReflectiveOperationException {
        return type.getDeclaredConstructor().newInstance();
    }

    public static void main(String[] args) throws Exception {
        System.out.println(nCopies(3, StringBuilder::new).size());
        System.out.println(create(ArrayList.class).isEmpty());
    }
}
Output
3
true

Bridge methods and heap pollution

When a class implements Comparable<Money>, its compareTo(Money) erases differently from the interface's compareTo(Object). The compiler generates a synthetic bridge method compareTo(Object) that casts and delegates, so polymorphism still works after erasure.

Java
import java.lang.reflect.Method;

public class Main {
    record Money(long cents) implements Comparable<Money> {
        public int compareTo(Money o) { return Long.compare(cents, o.cents); }
    }

    public static void main(String[] args) {
        java.util.List<String> found = new java.util.ArrayList<>();
        for (Method m : Money.class.getDeclaredMethods())
            if (m.getName().equals("compareTo"))
                found.add("compareTo(" + m.getParameterTypes()[0].getSimpleName() + ")"
                          + (m.isBridge() ? " <- bridge" : ""));
        java.util.Collections.sort(found);
        found.forEach(System.out::println);
    }
}
Output
compareTo(Money)
compareTo(Object) <- bridge

Heap pollution happens when a variable of a parameterised type refers to an object that isn't of that type — typically through raw types, unchecked casts or generic varargs (T... creates a T[]). The compiler warns (unchecked); @SafeVarargs marks a varargs method that doesn't misuse its array.

What is PECS?

Why is List<Integer> not a subtype of List<Number>?

What is type erasure and what are its consequences?

List<?> vs List<Object>?

Free for students and teachers

Write notes like this one

Cards for every idea, quizzes and flashcards, read aloud — on the web and your phone.

Something wrong with this note? Open it to report