StackWise
Modern JavaFundamentals

Generics

Type parameters, bounded types, wildcards and PECS, type erasure and its consequences.

intermediate 4 min readLesson 4 of 26

Why generics?

Before Java 5, collections held Object, so every read needed a cast — and a wrong cast failed at runtime:

List list = new ArrayList();
list.add("hello");
Integer n = (Integer) list.get(0);   // ClassCastException at runtime

Generics move that check to compile time:

List<String> list = new ArrayList<>();
list.add("hello");
Integer n = list.get(0);             // compile error

Generic classes and methods

public class Box<T> {                         // generic class
    private final T value;
    public Box(T value) { this.value = value; }
    public T get() { return value; }
    public <R> Box<R> map(Function<? super T, ? extends R> fn) {   // generic method
        return new Box<>(fn.apply(value));
    }
}

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

Common naming: T type, E element, K/V key/value, R result, N number.

Bounded type parameters

<T extends Bound> restricts the type argument and lets you call the bound’s methods:

public static <T extends Comparable<? super T>> T max(Collection<? extends T> items) {
    T best = null;
    for (T item : items) if (best == null || item.compareTo(best) > 0) best = item;
    return best;
}

Multiple bounds use &: <T extends Number & Comparable<T>> (a class bound must come first).

Wildcards and PECS

Generics are invariant: a List<Integer> is not a List<Number>, even though Integer is a Number. If it were, you could add a Double to a list of integers. Wildcards restore flexibility:

Wildcard Meaning You can You can’t
List<? extends Number> some unknown subtype of Number read as Number add (except null)
List<? super Integer> some unknown supertype of Integer add Integers read as anything but Object
List<?> some unknown type read as Object add

PECS — Producer extends, Consumer super: if a parameter produces values you read, use extends; if it consumes values you write, use super.

Pecs.javaJava 21
import java.util.*;

class Main {
    // src PRODUCES numbers -> extends;  dst CONSUMES them -> super
    static <T> void copy(List<? extends T> src, List<? super T> dst) {
        for (T item : src) dst.add(item);
    }

    static double sum(Collection<? extends Number> numbers) {    // read-only producer
        double total = 0;
        for (Number n : numbers) total += n.doubleValue();
        return total;
    }

    static void fillWithOnes(List<? super Integer> sink, int count) {   // write-only consumer
        for (int i = 0; i < count; i++) sink.add(1);
    }

    public static void main(String[] args) {
        List<Integer> ints = List.of(1, 2, 3);
        List<Double> doubles = List.of(1.5, 2.5);
        System.out.println(sum(ints) + " " + sum(doubles));     // both accepted thanks to ? extends

        List<Number> numbers = new ArrayList<>();
        List<Object> objects = new ArrayList<>();
        copy(ints, numbers);              // T = Integer: Integer -> Number
        copy(doubles, objects);           // T = Double:  Double  -> Object
        fillWithOnes(numbers, 2);
        System.out.println(numbers + " " + objects);

        // List<Number> n = ints;         // ✗ compile error: generics are invariant
        Number[] arr = new Integer[1];    // arrays are covariant...
        try { arr[0] = 2.5; }             // ...so this compiles but fails at runtime
        catch (ArrayStoreException e) { System.out.println("ArrayStoreException: " + e.getMessage()); }
    }
}

Type erasure

Generics exist only at compile time. The compiler checks types, inserts casts, and then erases type parameters to their bounds (Object if unbounded). At runtime, List<String> and List<Integer> are the same class.

Consequences:

new T();                                   // ✗ can't instantiate a type parameter
T[] arr = new T[10];                       // ✗ can't create generic arrays
if (obj instanceof List<String>) { }       // ✗ can't test parameterised types (List<?> is fine)
static T cache;                            // ✗ static fields can't use the class's T
void print(List<String> l) {}
void print(List<Integer> l) {}             // ✗ same erasure: both are print(List)

Workarounds pass a type token: Class<T> (EnumSet.noneOf(Color.class), entityManager.find(User.class, id)), or a Supplier<T> to create instances. Libraries like Jackson and Spring use super type tokens (new TypeReference<List<User>>() {}, ParameterizedTypeReference) because generic types of a subclass’s superclass survive erasure in class metadata.

Finished this lesson?

Mark it complete to track your progress through Modern Java.