Skip to main content
    Courses/Java/Advanced Generics

    Lesson 20 • Advanced

    Advanced Generics: Wildcards & Type Erasure

    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.

    What You'll Learn in This Lesson

    • You'll be able to write bounded type parameters with <T extends ...>
    • You'll be able to use recursive bounds like <T extends Comparable<T>>
    • You'll be able to apply PECS to choose ? extends vs ? super correctly
    • You'll be able to let the compiler infer type arguments for generic methods
    • You'll be able to explain type erasure and its runtime consequences
    • You'll be able to fix erasure limits with Class<T> tokens and @SafeVarargs

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

    List<? extends Fruit>

    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.

    List<? super Apple>

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

    List<Apple>

    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
    }
    Worked Example: Bounded & Recursive Bounds
    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
        }
    }
    Output
    sum ints:    6.0
    sum doubles: 4.0
    max int:    7
    max string: banana
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    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:

    • ? extends T — "T or any subtype". You can read as T, but cannot add.
    • ? super T — "T or any supertype". You can add a T, but reads only give Object.
    • ? — "any type at all". Read-only, as Object.

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

    Worked Example: PECS Wildcards & Inference
    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]
        }
    }
    Output
    sum(ints):    6.0
    sum(doubles): 4.0
    after fill:   [1, 2, 3, 4]
    after copy:   [seed, 10, 20, 30]
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    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

    ✅ The workarounds:

    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 & @SafeVarargs

    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.

    Worked Example: Erasure, Class<T> Tokens & @SafeVarargs
    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]
        }
    }
    Output
    same runtime class? true
    firstOrNull:        hello
    listOf:             [1, 2, 3]
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    🎯 Your Turn #1 — Bounds & Recursive Bounds

    Fill in the two bounds. The first lets you call .doubleValue(); the second makes compareTo type-safe. Run it and check your output against the comment at the bottom.
    Your Turn: Complete the 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
    }
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    🎯 Your Turn #2 — Pick the Right Wildcard

    One method reads (producer), one method writes (consumer). Apply PECS to fill in each wildcard, then confirm the expected output.
    Your Turn: Producer vs Consumer
    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]
    }
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    🏆 Mini-Challenge — Write a Generic max() (Support Faded)

    No blanks this time — just an outline. Write the whole generic maxOf method yourself from the comments. If you get stuck, peek back at Worked Example 1.
    Mini-Challenge: Generic max() over a List
    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
    }
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    Common Errors & Fixes

    • The method add(...) is not applicable for the type capture of ? extends Number — you tried to write into a ? extends (producer) list. Producers are read-only. Use ? super if you need to add.
    • incompatible types: List<Dog> cannot be converted to List<Animal> — generics are invariant. Accept List<? extends Animal> (to read) or List<? super Dog> (to write) instead.
    • generic array creationnew T[10] and new List<String>[10] are illegal because of erasure. Use a List<T> (or an Object[] cast you control) instead.
    • illegal generic type for instanceofx instanceof List<String> can't work after erasure. Test x instanceof List<?>, or carry a Class<T> token and call token.isInstance(x).
    • Possible heap pollution from parameterized vararg type — a generic T... parameter. If the method only reads the array, add @SafeVarargs to the method to confirm it's safe and clear the warning.

    📋 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

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

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

    Next up: Streams API — combine filter, map, collect, and flatMap into clean data pipelines, leaning on the generics fluency you just built.

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service