Generics

August 10, 20264 min readUpdated 8/20/2026

Generics let a class or method work with a type supplied by the caller, checked at compile time. List<String> is the everyday face of it. Writing your own is less common, and understanding erasure explains most of the surprising rules.

What they buy you

class Demo {
    void run() {
        // Before generics: everything was Object, and every read needed a cast
        List raw = new ArrayList();
        raw.add("hello");
        raw.add(42);                                  // nothing stops this
        String first = (String) raw.get(0);
        // String second = (String) raw.get(1);       // ClassCastException at RUNTIME

        List<String> typed = new ArrayList<>();
        typed.add("hello");
        // typed.add(42);                             // compile error — caught immediately
        String value = typed.get(0);                  // no cast needed
        System.out.println(value);
    }
}

Two gains: mistakes move from runtime to compile time, and casts disappear. A raw List still compiles today, with a warning — treat that warning as an error.

Generic classes and methods

// A class parameterised by one type
class Box<T> {
    private T contents;

    void put(T item) { this.contents = item; }
    T get() { return contents; }
}

class Demo {
    // A generic METHOD — the <T> before the return type declares the parameter
    static <T> List<T> firstTwo(List<T> items) {
        return items.subList(0, Math.min(2, items.size()));
    }

    void run() {
        Box<String> box = new Box<>();
        box.put("hello");
        System.out.println(box.get().length());       // typed, no cast

        System.out.println(firstTwo(List.of(1, 2, 3)));   // [1, 2] — T inferred as Integer
    }
}

The convention is single letters: T for type, E for element, K/V for key and value, R for result.

Bounded types

class Demo {
    // T must be Comparable, so compareTo is available inside
    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;
    }

    void run() {
        System.out.println(max(List.of(3, 1, 4)));          // 4
        System.out.println(max(List.of("Ana", "Bo")));      // Bo
    }
}

extends here means "is or extends" and works for interfaces too. Without the bound, T is only known to be an Object and compareTo would not compile.

Wildcards, and PECS

This is the part that confuses people, and one rule resolves most of it. Start with the surprise:

class Demo {
    void run() {
        List<Integer> numbers = List.of(1, 2, 3);
        // List<Number> widened = numbers;      // does NOT compile
    }
}

A List<Integer> is not a List<Number>, even though an Integer is a Number. If it were, you could add a Double to it through the wider reference and break the original list.

Wildcards restore the flexibility safely:

class Demo {
    // PRODUCER: reads from the list. `? extends` accepts Integer, Double, any Number.
    static double sum(List<? extends Number> numbers) {
        double total = 0;
        for (Number n : numbers) {
            total += n.doubleValue();
        }
        return total;                       // cannot ADD to numbers — see below
    }

    // CONSUMER: writes into the list. `? super` accepts List<Integer>, List<Number>, List<Object>.
    static void addNumbers(List<? super Integer> target) {
        target.add(1);
        target.add(2);                      // cannot READ a specific type out
    }

    void run() {
        System.out.println(sum(List.of(1, 2.5)));       // 3.5

        List<Number> target = new ArrayList<>();
        addNumbers(target);
        System.out.println(target);                      // [1, 2]
    }
}

PECS: Producer Extends, Consumer Super. If the parameter produces values you read, use ? extends. If it consumes values you write, use ? super. If it does both, use a plain type parameter.

The restrictions follow from safety. With ? extends Number the compiler knows every element is a Number but not which subtype the list actually holds — so reading is safe and adding is not. With ? super Integer the reverse holds.

Erasure, and what it explains

Generics exist only at compile time. The compiler checks the types, inserts casts, and then erases the type arguments — at runtime a List<String> is just a List. This was done so generic code could run on older JVMs, and it explains a set of rules that otherwise look arbitrary:

class Demo {
    void run(Object value) {
        List<String> a = new ArrayList<>();
        List<Integer> b = new ArrayList<>();

        // 1. Same class at runtime
        System.out.println(a.getClass() == b.getClass());    // true

        // 2. Cannot test a type argument
        if (value instanceof List<?> list) {                 // fine — wildcard only
            System.out.println(list.size());
        }
        // if (value instanceof List<String> s) { }          // does not compile

        // 3. Cannot create an array of a generic type
        // T[] array = new T[10];                            // does not compile
    }
}

A fourth consequence: you cannot overload two methods whose parameters differ only by type argument, because after erasure they have the same signature.

Keeping the type at runtime

When you genuinely need the type argument after erasure, the standard trick is to pass the class object alongside it:

class Repository<T> {
    private final Class<T> type;

    Repository(Class<T> type) {
        this.type = type;                    // the one thing erasure cannot take away
    }

    T cast(Object value) {
        return type.cast(value);             // checked, and no unchecked warning
    }

    String describe() {
        return "repository of " + type.getSimpleName();
    }
}

class Demo {
    void run() {
        Repository<String> repo = new Repository<>(String.class);
        System.out.println(repo.describe());          // repository of String
        System.out.println(repo.cast("hello").length());   // 5
    }
}

This is why so many framework APIs take a Class<T> parameter that looks redundant — objectMapper.readValue(json, Person.class) is exactly this pattern. The method cannot know T at runtime, so you hand it the evidence.

The practical rules

  • Never use a raw type. If you do not care what is in it, write List<?>, not List — the wildcard keeps the type checking.
  • Apply PECS to parameters, and use plain type parameters for return types. Wildcards in a return type push the problem onto every caller.
  • Do not suppress warnings without a comment saying why the cast is safe.
  • Prefer generic methods to generic classes when only one method needs the parameter.

Next

Multithreading is next — running code on more than one thread, and the ways that goes wrong.