Skip to content
Path to Engineer
All lessons
JavaCollections13 min read

Java generics: type parameters, bounds and erasure

Type parameters, bounded types and generic methods — plus what erasure means for the code you can and cannot write.

Day 54 of the 488-day pathway. Published in full — nothing held back.

Generics — type parameters, bounded types, generic methods

Generics exist because of Day 031's array covariance problem. Today is the mechanism; Day 055 is what it costs; Day 056 is how to use it properly.


L-054 · Generics

The problem they solve

Before Java 5:

List list = new ArrayList();
list.add("hello");
list.add(42);                              // no complaint
String s = (String) list.get(1);           // 💥 ClassCastException at RUNTIME

Every retrieval needed a cast, and every cast was a runtime gamble.

List<String> list = new ArrayList<>();
list.add("hello");
list.add(42);                              // ❌ COMPILE ERROR
String s = list.get(0);                    // no cast needed

Generics move type errors from runtime to compile time, and remove the casts. That's the entire value proposition, and it's the answer to "why do generics exist".

Type parameters

public class Box<T> {                      // T is a TYPE PARAMETER
    private T content;
    public void set(T content) { this.content = content; }
    public T get() { return content; }
}

Box<String> stringBox = new Box<>();       // T is bound to String — a TYPE ARGUMENT

Conventional single-letter names, and using them signals familiarity:

Letter Means
T Type
E Element (collections)
K, V Key, Value
N Number
R Return type
S, U Second, third types

The diamond <> (Java 7+) infers the type argument from the declaration:

Map<String, List<Integer>> m = new HashMap<String, List<Integer>>();   // pre-7
Map<String, List<Integer>> m = new HashMap<>();                         // ✅
var m = new HashMap<String, List<Integer>>();                           // Java 10+ (Day 030)

Generic methods

The type parameter belongs to the method, declared before the return type:

public static <T> void swap(T[] array, int i, int j) {
    T temp = array[i]; array[i] = array[j]; array[j] = temp;
}

public static <K, V> Map<V, K> invert(Map<K, V> map) { ... }

Type inference usually makes the call site clean:

swap(names, 0, 1);                 // T inferred as String
Collections.<String>emptyList();   // explicit, rarely needed

A generic method can live in a non-generic class, and its type parameter is independent of any class-level one. Collections is entirely non-generic with dozens of generic methods.

Bounded type parameters

extends constrains what T can be — and note it means "extends or implements", for both classes and interfaces:

public <T extends Number> double sum(List<T> list) {
    double total = 0;
    for (T n : list) total += n.doubleValue();      // ✅ Number's methods are available
    return total;
}

Without the bound, T is only known to be Object — you could call toString and nothing else.

Multiple bounds

public <T extends Comparable<T> & Serializable> T max(List<T> list) { ... }

The class bound must come first, and there can be at most one class among the bounds — single inheritance again (Day 037).

Recursive bounds — the pattern that looks strange

public static <T extends Comparable<T>> T max(List<T> list) { ... }

Read it as: "T must be comparable to itself." Without it, T extends Comparable would allow a List<Dog> where Dog implements Comparable<Cat> — comparable to the wrong thing.

This is why Enum is declared Enum<E extends Enum<E>> — it looks circular and isn't. It's saying "the type parameter is the concrete enum type itself", which is what makes compareTo and getDeclaringClass type-safe.

Being able to explain that self-referential bound is a genuine differentiator — most people find it baffling.

Generic classes vs generic methods

Use
Generic class The type is part of the object's identity — List<String>, Box<T>
Generic method The type is per-invocation — Collections.sort, Arrays.asList

Prefer a generic method when the type doesn't need to persist. A generic class forces every user to pick a type argument even when it's irrelevant to them.

Generics are invariant — the important rule

List<String> strings = new ArrayList<>();
List<Object> objects = strings;              // ❌ COMPILE ERROR

List<String> is NOT a subtype of List<Object>, even though String is a subtype of Object.

Why — this is the whole reason, and it's Day 031's array problem fixed:

// If it were allowed:
List<Object> objects = strings;      // hypothetically
objects.add(42);                     // legal for List<Object>
String s = strings.get(0);           // 💥 boom — an Integer in a List<String>

Compare with arrays, which ARE covariant and therefore unsound:

Object[] objects = new String[3];    // ✅ compiles — arrays are covariant
objects[0] = 42;                     // 💥 ArrayStoreException at RUNTIME
Arrays Generics
Variance Covariant Invariant
Type errors caught Runtime (ArrayStoreException) Compile time
Type info at runtime Retained (reified) Erased (Day 055)

Arrays and generics have opposite properties, which is exactly why they mix badly — Day 055 covers that.

Invariance is safe but restrictive, which is what wildcards exist to relax (Day 056).

Where generics can't go

class Box<T> {
    private T item;                        // ✅
    private static T shared;               // ❌ static — T isn't known per-class
    private T[] array = new T[10];         // ❌ cannot instantiate a generic array

    void method() {
        if (item instanceof T) { }         // ❌ erased at runtime (Day 055)
        T t = new T();                     // ❌ cannot instantiate T
    }
}

class MyException<T> extends Exception { } // ❌ cannot be generic and Throwable
List<int> nums;                             // ❌ primitives (Day 028) — use List<Integer>

Every one of these restrictions traces to erasure, which is tomorrow.

The generic array workaround:

@SuppressWarnings("unchecked")
private T[] array = (T[]) new Object[10];      // what ArrayList actually does

That's literally ArrayList's elementData (Day 046) — an Object[] cast on access. Which is why ArrayList.toArray() needs you to pass an array to get a correctly-typed result.


Type this yourself

import java.util.*;
import java.io.Serializable;

public class GenericsDemo {

    // ---- generic class ----
    static class Box<T> {
        private T content;
        void set(T c) { content = c; }
        T get() { return content; }
        <U> Pair<T, U> pairWith(U other) { return new Pair<>(content, other); }   // method-level U
    }

    record Pair<A, B>(A first, B second) { }

    // ---- generic methods ----
    static <T> void swap(T[] a, int i, int j) { T t = a[i]; a[i] = a[j]; a[j] = t; }

    static <T extends Number> double sum(List<T> list) {
        double total = 0;
        for (T n : list) total += n.doubleValue();
        return total;
    }

    // recursive bound: T must be comparable to ITSELF
    static <T extends Comparable<T>> T max(List<T> list) {
        T best = list.get(0);
        for (T t : list) if (t.compareTo(best) > 0) best = t;
        return best;
    }

    // multiple bounds — class first, then interfaces
    static <T extends Number & Comparable<T> & Serializable> T maxNumber(List<T> list) {
        return max(list);
    }

    // a typed generic "array" the way ArrayList does it
    static class SimpleList<T> {
        @SuppressWarnings("unchecked")
        private T[] data = (T[]) new Object[10];
        private int size;
        void add(T item) { data[size++] = item; }
        T get(int i) { return data[i]; }
    }

    public static void main(String[] args) {

        System.out.println("--- generic class ---");
        Box<String> box = new Box<>();
        box.set("hello");
        System.out.println("   get() = " + box.get() + "   (no cast needed)");
        System.out.println("   pairWith(42) = " + box.pairWith(42));

        System.out.println("\n--- generic methods ---");
        String[] names = {"a", "b", "c"};
        swap(names, 0, 2);
        System.out.println("   after swap: " + Arrays.toString(names));
        System.out.println("   sum([1, 2.5, 3L]) = " + sum(List.of(1, 2.5, 3L)));
        System.out.println("   max([3,7,2])      = " + max(List.of(3, 7, 2)));
        System.out.println("   max(['b','x','a'])= " + max(List.of("b", "x", "a")));

        System.out.println("\n--- invariance ---");
        List<String> strings = new ArrayList<>(List.of("a"));
        // List<Object> objects = strings;        // ← uncomment: compile error
        System.out.println("   List<String> is NOT a List<Object> — compile error if you try");

        System.out.println("\n--- arrays ARE covariant, and unsound ---");
        Object[] arr = new String[2];
        try { arr[0] = 42; }
        catch (ArrayStoreException e) {
            System.out.println("   Object[] o = new String[2]; o[0] = 42; → ArrayStoreException");
            System.out.println("   ↑ a RUNTIME error. Generics make the same mistake a COMPILE error.");
        }

        System.out.println("\n--- the ArrayList trick ---");
        SimpleList<String> sl = new SimpleList<>();
        sl.add("x"); sl.add("y");
        System.out.println("   " + sl.get(0) + sl.get(1) + "   (Object[] cast to T[] internally)");

        System.out.println("\n--- self-referential bound ---");
        System.out.println("   Enum is declared: Enum<E extends Enum<E>>");
        System.out.println("   → 'the type parameter IS the concrete enum type'");
        System.out.println("   Day.MON.compareTo(Day.TUE) = " + Day.MON.compareTo(Day.TUE)
                         + "   ← type-safe because of that bound");
    }

    enum Day { MON, TUE }
}

The key experiment: uncomment List<Object> objects = strings; and read the compile error. Then compare with the ArrayStoreException a few lines below — same logical mistake, one caught by the compiler and one at runtime. That contrast is the argument for generics in a single screen.


Common mistakes

Mistake Correction
Expecting List<String> to be a List<Object> Generics are invariant. Use wildcards (Day 056).
Using raw types (List with no argument) Loses all checking and generates unchecked warnings.
new T() or new T[10] Impossible — T is erased. Pass a factory or Class<T>.
static T field A static member is per-class; T is per-instance.
T extends Comparable without the recursive bound Allows comparison to the wrong type.
Making a class generic when a method would do Forces a type argument on every user.
List<int> Primitives can't be type arguments (Day 028).

Interview questions

Q: Why do generics exist?

To move type errors from runtime to compile time and eliminate casts. Before Java 5 a collection held Object, so every retrieval needed a cast that could fail at runtime with ClassCastException. Generics let the compiler verify element types at the point of insertion.

Q: Why are generics invariant when arrays are covariant?

Array covariance is unsound: you can assign a String[] to an Object[] and try to store an Integer, so the JVM has to check every store and throw ArrayStoreException at runtime. Generics chose invariance so the same mistake is a compile error — List<String> simply isn't a List<Object>. Wildcards then reintroduce controlled variance where it's safe.

Q: What is a bounded type parameter?

A constraint using extends, which covers both classes and interfaces. T extends Number means the compiler knows T's members and lets you call doubleValue. Without a bound, T is only known to be Object. Multiple bounds are joined with &, and any class bound must come first.

Q: What does <T extends Comparable<T>> mean, and why the repetition?

It's a recursive bound meaning "T must be comparable to itself". Without it, T extends Comparable would permit a type comparable to something else entirely. It's the same pattern as Enum<E extends Enum<E>>, which is how the enum's own type flows into compareTo and getDeclaringClass type-safely.

Q: Why can't you write new T[10]?

Because T is erased at runtime, so the JVM doesn't know what array type to allocate. The standard workaround, which ArrayList itself uses, is to allocate an Object[] and cast it to T[] with a suppressed unchecked warning.


Mini task

  1. Run GenericsDemo. Uncomment the invariance line and read the error.
  2. Write a generic Stack<T> with push, pop and peek, backed by an Object[].
  3. Write <K,V> Map<V,K> invert(Map<K,V>) and use it.
  4. Write a method with three bounds and explain the ordering rule.
  5. Try class MyException<T> extends Exception. Read the error and work out why.

Exit questions

  1. What two problems do generics solve?
  2. Name the conventional type-parameter letters.
  3. How do you declare a generic method, and when is it better than a generic class?
  4. What does extends mean in a bound, and what's the ordering rule for multiple bounds?
  5. Explain <T extends Comparable<T>> and Enum<E extends Enum<E>>.
  6. Why are generics invariant? Show the unsafe code it prevents.
  7. Contrast arrays and generics on variance, error timing and runtime type info.
  8. List four things you can't do with a type parameter, and the one reason for all of them.

Articulation drill

Two minutes: "Why can't you assign a List<String> to a List<Object>, when you can assign a String[] to an Object[]?"

Show the unsafe insertion in both, then note that one fails at compile time and one at runtime.


Previous: Day 053 · Tomorrow: Day 055 — type erasure, and what it costs