Advanced Generics: Wildcards & Type Erasure

Reviewed & published by Brayan K

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.

Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

📚 Before You Start

This lesson builds on a few things you've already met. You should be comfortable with:

🍎 A Real-World Analogy: Delivery Boxes

Picture three kinds of boxes arriving at a warehouse, and you'll never confuse the wildcards again.

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.

A write-only apple-friendly box. You can drop Apples in, but when you read items back all you know is they're Object.

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
}
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
    }
}

2️⃣ 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:

🧠 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 -> 7

You 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]
    }
}

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
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 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]
    }
}

🎯 Your Turn #1 — Bounds & Recursive Bounds

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)

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

📋 Quick Reference

SyntaxMeaningRead / Write
<T extends Number>Named param bounded to Number subtypesFull access to T
<T extends Comparable<T>>Recursive bound: T comparable to itselfFull access to T
? extends TProducer: T or a subtypeRead ✅ Write ❌
? super TConsumer: T or a supertypeRead as Object Write ✅
?Unbounded: any typeRead as Object only
Class<T>Type token (beats erasure)cast / isInstance
@SafeVarargsPromise: generic varargs are read-onlySilences heap-pollution warning

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

Practice quiz

In <T extends Number>, what does the bound let you do inside the method?

  • Call only Object methods on T
  • Create new T() instances
  • Call Number methods like doubleValue() on T
  • Add any type to a List<T>

Answer: Call Number methods like doubleValue() on T. Bounding T to Number means every T is at least a Number, so you can call Number methods such as doubleValue().

What does PECS stand for?

  • Producer Extends, Consumer Super
  • Parameter Erasure, Class Substitution
  • Producer Super, Consumer Extends
  • Polymorphic Extends, Concrete Super

Answer: Producer Extends, Consumer Super. PECS = Producer Extends, Consumer Super: read from a producer with ? extends T, write to a consumer with ? super T.

Given List<? extends Number> producer, which operation is allowed?

  • producer.add(1)
  • producer.add(new Object())
  • Both adding and reading freely
  • Reading elements as Number

Answer: Reading elements as Number. A ? extends wildcard is a producer: you can read elements as Number but cannot add (the exact subtype is unknown).

Why does <T extends Comparable<T>> include T inside Comparable?

  • It is a typo with no effect
  • It is a recursive bound ensuring T is comparable to its own type
  • It makes T a wildcard
  • It allows comparing T to any unrelated type

Answer: It is a recursive bound ensuring T is comparable to its own type. The recursive bound guarantees a.compareTo(b) is type-safe between two T values, exactly what Collections.max/sort require.

Because of type erasure, what do List<String> and List<Integer> share at runtime?

  • One runtime class, List
  • Nothing — they are different classes
  • A common String element type
  • A shared static field

Answer: One runtime class, List. Erasure removes type arguments at runtime, so both become plain List and share one runtime class object. Verified true on Java 21.

Which of these is ILLEGAL because of type erasure?

  • List<String> list = new ArrayList<>()
  • token.cast(x) with a Class<T>
  • new T() or new T[n]
  • x instanceof List<?>

Answer: new T() or new T[n]. You cannot write new T(), new T[n], or instanceof List<String> after erasure; workarounds include a Class<T> token.

What is a Class<T> type token used for?

  • To make a class final
  • To recover type information at runtime via cast/isInstance, beating erasure
  • To bound a wildcard
  • To create generic arrays

Answer: To recover type information at runtime via cast/isInstance, beating erasure. Passing String.class (a Class<String>) lets you use token.cast(...) and token.isInstance(...) since the type argument is gone at runtime.

Why does @SafeVarargs exist?

  • To make varargs faster
  • To allow more than one vararg parameter
  • To convert checked to unchecked exceptions
  • To document that a generic-varargs method only reads its array, silencing the heap-pollution warning

Answer: To document that a generic-varargs method only reads its array, silencing the heap-pollution warning. Generic varargs create a hidden array of an erased type; @SafeVarargs promises the method only reads it, suppressing the false heap-pollution warning.

Why can't you assign a List<Dog> to a List<Animal> even though Dog extends Animal?

  • Generics are covariant
  • Generics are invariant; allowing it would let you add a Cat to a List<Dog>
  • Dog does not really extend Animal
  • Lists are immutable

Answer: Generics are invariant; allowing it would let you add a Cat to a List<Dog>. Generics are invariant; if the assignment were allowed you could add a Cat through the List<Animal> view, breaking type safety. Use wildcards for variance.

What is the difference between <T extends Number> and <? extends Number>?

  • They are identical
  • The wildcard can be returned but the named one cannot
  • The named T can be returned and related across arguments; the wildcard is unnamed and read-only
  • Only the wildcard allows adding elements

Answer: The named T can be returned and related across arguments; the wildcard is unnamed and read-only. A named type parameter ties the type across arguments and the return value; the wildcard means 'some unknown subtype' and only supports reading.

Continue this course

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

Related lessons