Skip to main content
Java · lesson 61 of 66
Open the Java page →
Java intermediate Lesson 61 of 66

Java Generics: Bounded Types, Wildcards, and Generic Methods

Master generics beyond basic <T> — bounded types, wildcards (? extends vs ? super), and generic methods that work across collections.

By SkillByExample · Updated

This lesson goes past List<String>: bounds that let generic code call methods on T, the wildcards ? extends and ? super, generic methods, and what type erasure takes away at runtime. Every example is one file run with the JDK 21 source launcher (java File.java), and several show the compile error the generic rules exist to produce.

Bounded type parameters

<T extends Comparable<T>> promises the compiler that every T has compareTo, so the method can call it.

import java.util.List;

public class Bounded {
    static <T extends Comparable<T>> T max(List<T> items) {
        T best = items.get(0);
        for (T item : items) {
            if (item.compareTo(best) > 0) best = item;
        }
        return best;
    }

    public static void main(String[] args) {
        System.out.println(max(List.of(3, 1, 4, 1, 5, 9, 2, 6)));
        System.out.println(max(List.of("apple", "zebra", "mango")));
        System.out.println(max(List.of(new Object(), new Object())));
    }
}
$ java Bounded.java
Bounded.java:15: error: method max in class Bounded cannot be applied to given types;
        System.out.println(max(List.of(new Object(), new Object())));
                           ^
  required: List<T>
  found:    List<Object>
  reason: inference variable E has incompatible bounds
    upper bounds: Comparable<T>,Object
    lower bounds: Object
  where T,E are type-variables:
    T extends Comparable<T> declared in method <T>max(List<T>)
    E extends Object declared in method <E>of(E,E)
1 error
error: compilation failed

Object doesn’t implement Comparable, so the call is rejected at compile time rather than failing inside max. Delete that line and the program prints 9 and zebra. Without the bound, item.compareTo(best) wouldn’t compile at all — a plain T only has Object’s methods.

Multiple bounds

A type variable can require a class and several interfaces, joined with &; the class comes first.

public class MultiBound {
    interface Named { String name(); }

    static final class Score extends Number implements Named, Comparable<Score> {
        private final String name; private final int value;
        Score(String name, int value) { this.name = name; this.value = value; }
        public String name()         { return name; }
        public int intValue()        { return value; }
        public long longValue()      { return value; }
        public float floatValue()    { return value; }
        public double doubleValue()  { return value; }
        public int compareTo(Score o) { return Integer.compare(value, o.value); }
    }

    static <T extends Number & Named & Comparable<T>> String describe(T a, T b) {
        T winner = a.compareTo(b) >= 0 ? a : b;
        return winner.name() + " wins with " + winner.doubleValue();
    }

    public static void main(String[] args) {
        System.out.println(describe(new Score("ann", 82), new Score("bo", 91)));
    }
}
$ java MultiBound.java
bo wins with 91.0

Inside describe, T has doubleValue() from Number, name() from Named and compareTo from Comparable — all three bounds apply at once.

Generics are invariant

A List<Integer> is not a List<Number>, even though an Integer is a Number.

import java.util.ArrayList;
import java.util.List;

public class Invariance {
    static double sum(List<Number> nums) {
        double total = 0;
        for (Number n : nums) total += n.doubleValue();
        return total;
    }

    public static void main(String[] args) {
        List<Integer> ints = new ArrayList<>(List.of(1, 2, 3));
        System.out.println(sum(ints));
    }
}
$ java Invariance.java
Invariance.java:13: error: method sum in class Invariance cannot be applied to given types;
        System.out.println(sum(ints));
                           ^
  required: List<Number>
  found:    List<Integer>
  reason: argument mismatch; List<Integer> cannot be converted to List<Number>
1 error
error: compilation failed

If this were allowed, sum could call nums.add(2.5) and put a Double into a list the caller believes holds only Integers. The compiler forbids the conversion instead.

? extends T: read-only access for producers

An upper-bounded wildcard accepts a list of T or any subtype. You can read elements as T, but you cannot add to it.

import java.util.ArrayList;
import java.util.List;

public class UpperBound {
    static double sum(List<? extends Number> nums) {
        double total = 0;
        for (Number n : nums) total += n.doubleValue();
        return total;
    }

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

        List<? extends Number> view = new ArrayList<Integer>();
        view.add(4);
    }
}
$ java UpperBound.java
UpperBound.java:16: error: no suitable method found for add(int)
        view.add(4);
            ^
    method List.add(CAP#1) is not applicable
      (argument mismatch; int cannot be converted to CAP#1)
    method List.add(int,CAP#1) is not applicable
      (actual and formal argument lists differ in length)
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Number from capture of ? extends Number
1 error
error: compilation failed

sum now takes List<Integer> and List<Double>. But through a List<? extends Number> the compiler doesn’t know which subtype the list holds (CAP#1), so it rejects every add except null — the list could be a List<Double>. Remove the last two lines and the program prints 6.0 and 4.0.

? super T: write access for consumers

A lower-bounded wildcard accepts a list of T or any supertype. You can add Ts, but reading gives you only Object.

import java.util.ArrayList;
import java.util.List;

public class LowerBound {
    static void addCountdown(List<? super Integer> sink) {
        for (int i = 3; i >= 1; i--) sink.add(i);
    }

    public static void main(String[] args) {
        List<Integer> ints = new ArrayList<>();
        List<Number> nums = new ArrayList<>(List.of(0.5));
        List<Object> objs = new ArrayList<>(List.of("start"));
        addCountdown(ints);
        addCountdown(nums);
        addCountdown(objs);
        System.out.println(ints + " " + nums + " " + objs);

        List<? super Integer> sink = objs;
        Object first = sink.get(0);
        System.out.println("read back as Object: " + first);
    }
}
$ java LowerBound.java
[3, 2, 1] [0.5, 3, 2, 1] [start, 3, 2, 1]
read back as Object: start

Any list that can hold an Integer is accepted. Reading through ? super Integer gives Object, because the list might be a List<Object> holding a String — exactly what happened here.

PECS: producer extends, consumer super

When a method reads from one collection and writes to another, use both wildcards. This is the signature of Collections.copy.

import java.util.*;

public class Pecs {
    static <T> void copyInto(List<? super T> dst, List<? extends T> src) {
        for (T item : src) dst.add(item);
    }

    public static void main(String[] args) {
        List<Integer> source = List.of(10, 20, 30);
        List<Number> numbers = new ArrayList<>();
        List<Object> objects = new ArrayList<>();
        copyInto(numbers, source);
        Pecs.<Number>copyInto(objects, List.of(1.5, 2.5));
        System.out.println(numbers + " " + objects);

        List<Integer> nums = new ArrayList<>(List.of(5, 3, 8));
        Collections.sort(nums, Comparator.<Number>comparingDouble(Number::doubleValue));
        System.out.println(nums);
    }
}
$ java Pecs.java
[10, 20, 30] [1.5, 2.5]
[3, 5, 8]

src produces Ts (extends); dst consumes them (super). The last example uses a Comparator<Number> to sort a List<Integer>: sort declares Comparator<? super T>, so a comparator for any supertype works. Pecs.<Number>copyInto shows an explicit type argument when you want to choose T yourself.

Generic methods and inference

A generic method declares its own type parameter before the return type. The compiler infers T from the arguments.

import java.util.*;

public class GenericMethods {
    static <T> void swap(List<T> list, int i, int j) {
        T tmp = list.get(i);
        list.set(i, list.get(j));
        list.set(j, tmp);
    }

    static <K, V> Map<V, K> invert(Map<K, V> map) {
        Map<V, K> out = new TreeMap<>();
        map.forEach((k, v) -> out.put(v, k));
        return out;
    }

    public static void main(String[] args) {
        List<String> words = new ArrayList<>(List.of("first", "middle", "last"));
        swap(words, 0, 2);
        System.out.println(words);
        System.out.println(invert(Map.of("one", 1, "two", 2, "three", 3)));
        var mixed = List.of(1, 2.5);
        System.out.println(mixed.get(0).getClass().getSimpleName() + " " + mixed.get(1).getClass().getSimpleName());
    }
}
$ java GenericMethods.java
[last, middle, first]
{1=one, 2=two, 3=three}
Integer Double

invert has two type parameters and swaps them in the result type. For List.of(1, 2.5) the compiler infers the closest common type of Integer and Double — a Number & Comparable<...> intersection — and the elements keep their own runtime classes.

Type erasure

Generic type arguments exist only at compile time. At runtime a List<String> and a List<Integer> are the same class, which rules out some code.

import java.util.*;

public class Erasure {
    public static void main(String[] args) {
        List<String> strings = new ArrayList<>();
        List<Integer> ints = new ArrayList<>();
        System.out.println(strings.getClass() == ints.getClass());
        System.out.println(strings.getClass().getName());

        Object o = strings;
        if (o instanceof List<?> l) System.out.println("a List of something, size " + l.size());
        if (o instanceof List<String>) System.out.println("a List of String");
    }
}
$ java Erasure.java
Erasure.java:12: error: Object cannot be safely cast to List<String>
        if (o instanceof List<String>) System.out.println("a List of String");
            ^
1 error
error: compilation failed

The runtime can’t tell a List<String> from any other List, so instanceof List<String> is rejected; List<?> is fine. Removing that line prints true, java.util.ArrayList and a List of something, size 0. Erasure is also why new T[n] and new T() don’t compile — there is no T at runtime to instantiate.

Practice

  1. Write <T extends Comparable<T>> boolean isSorted(List<T> list) and test it on a sorted and an unsorted list.
Solution
import java.util.List;

public class IsSorted {
    static <T extends Comparable<T>> boolean isSorted(List<T> list) {
        for (int i = 1; i < list.size(); i++) {
            if (list.get(i - 1).compareTo(list.get(i)) > 0) return false;
        }
        return true;
    }

    public static void main(String[] args) {
        System.out.println(isSorted(List.of(1, 2, 3, 4)));
        System.out.println(isSorted(List.of("b", "a")));
        System.out.println(isSorted(List.<Integer>of()));
    }
}
$ java IsSorted.java
true
false
true

An empty list is sorted: the loop never runs.

  1. Write addAll(Collection<? super T> dst, T... items) and use it to add Integers to a List<Number>.
Solution
import java.util.*;

public class AddAll {
    @SafeVarargs
    static <T> void addAll(Collection<? super T> dst, T... items) {
        for (T item : items) dst.add(item);
    }

    public static void main(String[] args) {
        List<Number> nums = new ArrayList<>(List.of(0.5));
        addAll(nums, 1, 2, 3);
        System.out.println(nums);
    }
}
$ java AddAll.java
[0.5, 1, 2, 3]

dst consumes Ts, so it is ? super T. @SafeVarargs silences the warning about a generic varargs array; it is safe here because the method only reads the array.

  1. Write sumAll(List<? extends List<? extends Number>> lists) that totals a list of lists, and call it with a List<List<Integer>>.
Solution
import java.util.List;

public class SumAll {
    static double sumAll(List<? extends List<? extends Number>> lists) {
        double total = 0;
        for (List<? extends Number> inner : lists)
            for (Number n : inner) total += n.doubleValue();
        return total;
    }

    public static void main(String[] args) {
        List<List<Integer>> grid = List.of(List.of(1, 2), List.of(3, 4, 5));
        System.out.println(sumAll(grid));
    }
}
$ java SumAll.java
15.0

Both levels need a wildcard: List<List<? extends Number>> would not accept a List<List<Integer>>, for the same invariance reason as before.

  1. Two overloads print(List<String>) and print(List<Integer>) in one class: predict whether they compile, then try it.
Solution
import java.util.List;

public class Overloads {
    static void print(List<String> list)  { System.out.println("strings " + list); }
    static void print(List<Integer> list) { System.out.println("ints " + list); }

    public static void main(String[] args) {
        print(List.of("a"));
    }
}
$ java Overloads.java
Overloads.java:5: error: name clash: print(List<Integer>) and print(List<String>) have the same erasure
    static void print(List<Integer> list) { System.out.println("ints " + list); }
                ^
1 error
error: compilation failed

After erasure both become print(List), so the class would contain two identical methods. Give them different names, such as printStrings and printInts.

Frequently Asked Questions

What's the difference between &lt;? extends T&gt; and &lt;? super T&gt;?
Use '? extends T' when you only READ from a structure (covariance). Use '? super T' when you only WRITE to a structure (contravariance). Remember PECS: Producer-Extends, Consumer-Super.
Why can't I create a generic array like new T[size]?
Due to type erasure, T doesn't exist at runtime. The compiler can't allocate an array of a type that doesn't exist. Use ArrayList instead, or cast from Object[] with @SuppressWarnings.
What does a bounded type parameter mean?
Bounded type parameters (<T extends Comparable<T>>) restrict what types can be substituted. This lets you call methods from the bound (like compareTo) without a cast.
Why is a List<Integer> not a List<Number> in Java?
Generic types are invariant. If a List<Integer> could be used as a List<Number>, code could add a Double to it and break every reader expecting Integers. To accept lists of any Number subtype for reading, declare the parameter as List<? extends Number>.