Guide contents

Lesson 11 of 71

Generic Types, Bounds, and Wildcards

Generics let a class or method work with many types while the compiler checks that the types fit together. This lesson covers how to declare generic types and methods, how bounds and wildcards control which types are accepted, and how to read signatures such as <T extends Comparable<? super T>>. After it, a reader can write and read the generic signatures found in the JDK and in library APIs.

Idea Syntax
Generic class or interface class Box<T>
Generic method static <T> T first(List<T> list)
Diamond new Box<>(...)
Bounded type parameter <T extends Number>
Upper-bounded wildcard List<? extends Number> (read)
Lower-bounded wildcard List<? super Integer> (write)
Unbounded wildcard List<?>
Recursive bound <E extends Enum<E>>

Generics are a compile-time feature (Java 5). How the compiler removes type arguments from the bytecode is the topic of Type Erasure.

Example 1: a generic class and the diamond

A type parameter is a name, such as T, that stands for a type the caller chooses. A type argument is the real type the caller supplies, such as String in Box<String>. The diamond <> (Java 7) asks the compiler to infer the type arguments of a constructor call from the target type.

Java
public class BoxDemo {
    static final class Box<T> {
        private final T value;

        Box(T value) { this.value = value; }

        T get() { return value; }

        @Override
        public String toString() { return "Box[" + value + "]"; }
    }

    public static void main(String[] args) {
        Box<String> s = new Box<>("hello");      // diamond: T = String
        Box<Integer> n = new Box<>(42);
        String text = s.get();                   // no cast needed
        int doubled = n.get() * 2;               // auto-unboxing of Integer
        System.out.println(s + " " + text.length());
        System.out.println(n + " " + doubled);
        var inferred = new Box<>(3.5);           // var + diamond: Box<Double>
        System.out.println(inferred.get().getClass().getSimpleName());
    }
}
Output
Box[hello] 5
Box[42] 84
Double

Without generics, get would return Object and every caller would cast. With Box<String>, the compiler inserts the cast and rejects s = new Box<>(42) at compile time. A type argument must be a reference type, so Box<int> does not compile; Box<Integer> with autoboxing is used instead.

Example 2: generic methods and type inference

A generic method declares its own type parameters before the return type. The compiler infers them from the arguments and, when needed, from the target type of the call. An explicit type witness, Util.<String>pick(...), is allowed but rarely needed.

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

public class GenericMethods {
    static <T> T firstOrDefault(List<T> list, T fallback) {
        return list.isEmpty() ? fallback : list.get(0);
    }

    static <K, V> String pair(K key, V value) {
        return key + "=" + value;
    }

    static <T> List<T> emptyList() {
        return new ArrayList<>();
    }

    public static void main(String[] args) {
        System.out.println(firstOrDefault(List.of("a", "b"), "none"));
        System.out.println(firstOrDefault(List.<String>of(), "none"));
        System.out.println(pair("id", 7));

        List<Integer> ints = emptyList();        // T inferred from the target type
        ints.add(5);
        System.out.println(ints);

        Comparable<?> c = firstOrDefault(List.of(1), "x");   // T inferred as a common supertype
        System.out.println(c);
    }
}
Output
a
none
id=7
[5]
1

In the last call the arguments are an Integer list and a String, so the compiler infers T as a common supertype of both (an intersection that includes Serializable and Comparable<...>). Inference picks the most specific type that fits all constraints; differing argument types do not by themselves cause an error.

Example 3: bounded type parameters

A bound limits the type arguments a caller may supply. <T extends Number> accepts Number and its subclasses and lets the body call Number methods on T. The keyword is extends for both classes and interfaces, and & adds more bounds (<T extends Number & Comparable<T>>, class first).

Java
import java.util.List;

public class Bounds {
    static <T extends Number> double sum(List<T> values) {
        double total = 0;
        for (T v : values) total += v.doubleValue();   // allowed by the bound
        return total;
    }

    static <T extends Comparable<T>> T max(List<T> values) {
        T best = values.get(0);
        for (T v : values) if (v.compareTo(best) > 0) best = v;
        return best;
    }

    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)));
        System.out.println(max(List.of("pear", "apple", "plum")));
        System.out.println(max(List.of(4, 9, 2)));
    }
}
Output
6.0
4.0
plum
9

sum(List.of("a")) fails to compile because String is not a Number. The bound moves a runtime error (a failed cast) to compile time.

Example 4: invariance

Generic types are invariant: List<Integer> is not a subtype of List<Number>, even though Integer is a subtype of Number. If it were, code could put a Double into a list of integers. Arrays are covariant (Integer[] is a Number[]), so the same mistake compiles with arrays and fails at run time.

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

public class Invariance {
    public static void main(String[] args) {
        Integer[] ints = {1, 2};
        Number[] nums = ints;                  // compiles: arrays are covariant
        try {
            nums[0] = 3.14;                    // runtime type check on store
        } catch (ArrayStoreException e) {
            System.out.println("ArrayStoreException: " + e.getMessage());
        }

        List<Integer> intList = new ArrayList<>(List.of(1, 2));
        // List<Number> numList = intList;     // does not compile
        List<? extends Number> view = intList; // compiles: read-only view of numbers
        Number n = view.get(0);
        System.out.println("read " + n + " from " + view);
    }
}
Output
ArrayStoreException: java.lang.Double
read 1 from [1, 2]

The commented line is rejected with "incompatible types". The wildcard type List<? extends Number> is the subtype relation that is safe: it allows reading Number values but not adding them, as the next example shows.

Example 5: extends, super, and PECS

? extends T means "some unknown subtype of T": elements can be read as T, but nothing except null can be added, because the exact type is unknown. ? super T means "some unknown supertype of T": a T can be added, but reads return Object. The rule PECS (producer extends, consumer super) states the choice: a parameter that produces values for the method uses extends; a parameter that consumes values from the method uses super. Collections.copy(List<? super T> dest, List<? extends T> src) follows it.

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

public class Pecs {
    static <T> void copy(List<? super T> dest, List<? extends T> src) {
        for (T item : src) dest.add(item);    // read from producer, write to consumer
    }

    static double total(List<? extends Number> producer) {
        double t = 0;
        for (Number n : producer) t += n.doubleValue();
        return t;
    }

    static void fill(List<? super Integer> consumer, int count) {
        for (int i = 0; i < count; i++) consumer.add(i);
    }

    public static void main(String[] args) {
        List<Number> numbers = new ArrayList<>();
        copy(numbers, List.of(1, 2));          // T = Integer: Integer list into Number list
        copy(numbers, List.of(2.5));           // T = Double
        System.out.println(numbers + " total=" + total(numbers));

        List<Object> objects = new ArrayList<>();
        fill(objects, 3);                      // List<Object> accepts Integers
        Object first = objects.get(0);         // reads from ? super are Object
        System.out.println(objects + " first=" + first);
        System.out.println(total(List.of(1, 2L, 3.0f)));
    }
}
Output
[1, 2, 2.5] total=5.5
[0, 1, 2] first=0
6.0

With exact types (List<T> dest, List<T> src), copy(numbers, List.of(1, 2)) would not compile, because no single T is both Number and Integer. Wildcards make the method accept every safe combination. Return types normally do not use wildcards, because callers would then have to handle the unknown type.

Example 6: unbounded wildcards and wildcard capture

List<?> is a list of some unknown type. It suits methods that use only Object methods or the list structure (size, clear). Adding to it fails to compile. A helper method with a type parameter can "capture" the unknown type: the compiler gives the ? a temporary name (shown as CAP#1 in error messages) and binds the helper's T to it. This is wildcard capture.

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

public class Capture {
    static int countNulls(List<?> list) {
        int n = 0;
        for (Object o : list) if (o == null) n++;
        return n;
    }

    // Public API stays simple: callers pass any list.
    static void swapFirstLast(List<?> list) {
        swapHelper(list);                     // ? captured as T
    }

    private static <T> void swapHelper(List<T> list) {
        T first = list.get(0);
        list.set(0, list.get(list.size() - 1));   // allowed: both are T
        list.set(list.size() - 1, first);
    }

    public static void main(String[] args) {
        List<String> words = new ArrayList<>(List.of("a", "b", "c"));
        swapFirstLast(words);
        System.out.println(words);

        List<Integer> nums = new ArrayList<>();
        nums.add(1); nums.add(null); nums.add(null);
        System.out.println("nulls = " + countNulls(nums));
    }
}
Output
[c, b, a]
nulls = 2

Writing list.set(0, list.get(1)) directly on a List<?> does not compile: the compiler cannot prove the two unknown types are the same. Inside the helper both are T, so the code type-checks. The JDK uses the same pattern in Collections.swap and Collections.reverse.

Example 7: Comparable<? super T> and recursive bounds

<T extends Comparable<? super T>> reads as "T can be compared with itself or with a supertype". It is needed when a class inherits compareTo from a parent: Manager extends Employee where Employee implements Comparable<Employee>. A Manager is Comparable<Employee>, not Comparable<Manager>, so the stricter bound from Example 3 rejects it.

A recursive bound mentions the type parameter inside its own bound. Enum<E extends Enum<E>> makes compareTo(E) in Enum accept only constants of the same enum.

Java
import java.util.List;

public class RecursiveBounds {
    static class Employee implements Comparable<Employee> {
        final int salary;
        Employee(int salary) { this.salary = salary; }
        public int compareTo(Employee o) { return Integer.compare(salary, o.salary); }
        public String toString() { return getClass().getSimpleName() + "(" + salary + ")"; }
    }

    static final class Manager extends Employee {
        Manager(int salary) { super(salary); }
    }

    static <T extends Comparable<? super T>> T max(List<? extends T> values) {
        T best = values.get(0);
        for (T v : values) if (v.compareTo(best) > 0) best = v;
        return best;
    }

    enum Size { SMALL, LARGE }

    static <E extends Enum<E>> E last(Class<E> type) {
        E[] all = type.getEnumConstants();
        return all[all.length - 1];
    }

    public static void main(String[] args) {
        List<Manager> managers = List.of(new Manager(120), new Manager(150));
        Manager top = max(managers);            // T = Manager, Comparable<Employee> accepted
        System.out.println(top);
        System.out.println(last(Size.class));
        System.out.println(Size.SMALL.compareTo(Size.LARGE));
        // Size.SMALL.compareTo(Thread.State.NEW) does not compile: E is fixed to Size
    }
}
Output
Manager(150)
LARGE
-1

This is the signature of Collections.max: <T extends Object & Comparable<? super T>> T max(Collection<? extends T>). The extra Object & bound exists so the erased return type stays Object for binary compatibility, which Type Erasure explains.

Example 8: a builder hierarchy with a self type

Builders that subclass each other lose the subclass type after calling an inherited setter: child.name("x") returns the parent builder, and the child's own setters become unreachable in a chain. A recursive type parameter B extends Builder<B> plus an abstract self() method returns the subclass type from every setter. This pattern is called the simulated self type.

Java
public class SelfTypeBuilder {
    static class Order {
        final String symbol; final int qty; final double limit;
        Order(String symbol, int qty, double limit) {
            this.symbol = symbol; this.qty = qty; this.limit = limit;
        }
        public String toString() {
            return getClass().getSimpleName() + "[" + symbol + " x" + qty
                    + (limit > 0 ? " @" + limit : "") + "]";
        }
    }

    static final class LimitOrder extends Order {
        LimitOrder(String s, int q, double l) { super(s, q, l); }
    }

    abstract static class Builder<B extends Builder<B>> {
        String symbol; int qty;
        B symbol(String s) { this.symbol = s; return self(); }
        B qty(int q) { this.qty = q; return self(); }
        abstract B self();
        abstract Order build();
    }

    static final class MarketBuilder extends Builder<MarketBuilder> {
        MarketBuilder self() { return this; }
        Order build() { return new Order(symbol, qty, 0); }
    }

    static final class LimitBuilder extends Builder<LimitBuilder> {
        double limit;
        LimitBuilder limit(double l) { this.limit = l; return this; }
        LimitBuilder self() { return this; }
        LimitOrder build() { return new LimitOrder(symbol, qty, limit); }
    }

    public static void main(String[] args) {
        Order m = new MarketBuilder().symbol("AAPL").qty(100).build();
        LimitOrder l = new LimitBuilder().symbol("MSFT").qty(50).limit(410.5).build();
        System.out.println(m);
        System.out.println(l);
    }
}
Output
Order[AAPL x100]
LimitOrder[MSFT x50 @410.5]

In new LimitBuilder().symbol("MSFT").qty(50).limit(410.5), symbol and qty are declared in Builder but return LimitBuilder, so limit is still available. LimitBuilder.build uses a covariant return type to give callers a LimitOrder without a cast.

Interview points

  • Generic types are invariant: List<Integer> is not a List<Number>. Arrays are covariant and check stores at run time with ArrayStoreException.
  • PECS: a parameter the method reads from uses ? extends T; a parameter the method writes to uses ? super T. A parameter used both ways uses T.
  • From List<? extends T> nothing but null can be added. From List<? super T> reads return Object.
  • List<?> differs from the raw List: the wildcard type is still checked and rejects add.
  • Wildcard capture: a private generic helper names the unknown type so the body can read and write it.
  • <T extends Comparable<? super T>> lets subclasses that inherit compareTo from a parent be used.
  • Enum<E extends Enum<E>> and self-typed builders use recursive bounds so methods accept or return the exact subtype.
  • Wildcards belong in parameter types, not in return types.

Summary

  • A type parameter is declared on a class, interface, or method; a type argument is the type a caller supplies.
  • The diamond (Java 7) and generic method inference fill in type arguments from arguments and the target type.
  • <T extends X & Y> bounds a type parameter; the body can call members of the bounds.
  • ? extends, ? super, and ? relax invariance for reading, writing, and structure-only use.
  • Type arguments must be reference types; primitives are boxed.
  • Recursive bounds give "same type as me" relations in Enum, Comparable, and builder hierarchies.