← All posts

Software engineering · java · generics · type-systems · api-design

Understanding Java Generics

How Java generics work, from type parameters and wildcards to erasure, heap pollution and API design, explained from the basics up to what senior engineers need to know.

Why labels on boxes matter

Imagine a warehouse where every box is plain brown cardboard. Workers can put anything in any box, and nobody finds out that the box for the paint shop contains fish until someone opens it in the paint shop. Now imagine every box has a printed label, and the loading dock refuses to put fish into a box labelled “paint”. The boxes are the same; the mistake is caught at the door instead of at the destination.

Generics are those labels. Before Java 5, a List could hold anything, and a wrong item only surfaced as a ClassCastException somewhere far from the line that put it there. Generics let you write List<String>, so the compiler rejects the wrong item on the line that adds it. They also let you write one sort or one Cache that works for any type, without copying code or giving up type safety.

Most Java developers use generics every day and are comfortable up to List<String>. The interesting part, the part that shows up in library design, code reviews and production incidents, is everything after that: wildcards, erasure, and the places where the compiler’s promises quietly end. This article goes from the basics to those edges.

The basics: type parameters

A type parameter is a placeholder for a type, written in angle brackets. You declare it once and then use it like any other type:

public class Box<T> {
    private T value;

    public void set(T value) { this.value = value; }
    public T get() { return value; }
}

Box<String> box = new Box<>();
box.set("hello");
String s = box.get();   // no cast needed
box.set(42);            // compile error: an int is not a String

T is the type parameter, String in Box<String> is the type argument, and Box<String> as a whole is a parameterised type. The <> on the right, called the diamond, asks the compiler to work out the type argument from the left-hand side.

By convention, type parameters are single capital letters:

Letter Usually means Example
T, U Any type Box<T>, Pair<T, U>
E Element of a collection List<E>
K, V Key and value Map<K, V>
R Result Function<T, R>
X Exception Optional.orElseThrow(Supplier<? extends X>)

Generic methods

A method can declare its own type parameters, written before the return type, even inside a class that is not generic:

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

String name = firstOrDefault(names, "unknown"); // T is inferred as String

You rarely write the type argument yourself. The compiler infers it from the arguments and from where the result is going. When it cannot, you can supply it explicitly with a type witness, such as Collections.<String>emptyList().

Bounded type parameters

Sometimes “any type” is too broad. To find the largest item you must be able to compare items, so you restrict T with a bound:

public static <T extends Comparable<T>> T max(List<T> list) {
    T best = list.get(0);
    for (T item : list) {
        if (item.compareTo(best) > 0) best = item; // allowed: T is Comparable
    }
    return best;
}

In a bound, extends means “is, extends or implements”, for classes and interfaces alike. A type parameter can have several bounds joined with &, with at most one class, listed first: <T extends Number & Comparable<T>>. We will improve this max signature shortly.

Invariance: why a List is not a List

Here is the rule that surprises everyone once. Integer is a subtype of Number, but List<Integer> is not a subtype of List<Number>. Generic types are invariant.

The reason becomes obvious if you imagine that it were allowed:

List<Integer> ints = new ArrayList<>();
List<Number> nums = ints;   // suppose this compiled
nums.add(3.14);             // a Double is a Number, so this looks fine
Integer i = ints.get(0);    // but ints now holds a Double

A list you can both read from and write to cannot safely be treated as a list of its supertype. Arrays made the opposite choice back in Java 1.0. They are covariant, so an Integer[] is a Number[], and the same mistake compiles:

Number[] nums = new Integer[1];
nums[0] = 3.14;             // compiles, then throws ArrayStoreException

Arrays get away with this because every array knows its element type at runtime and checks every store. As we will see, generic objects do not carry that information at runtime, so the compiler has to be strict instead.

Wildcards: flexibility with a contract

Invariance is safe but rigid. A method that adds up numbers should accept a List<Integer> and a List<Double>. Wildcards provide that flexibility:

  • List<? extends Number>: a list of some type that is Number or a subtype. You can read Numbers from it, but you cannot add to it (except null), because you don’t know which subtype it really holds.
  • List<? super Integer>: a list of some type that is Integer or a supertype, such as List<Number> or List<Object>. You can add Integers to it, but reading only promises Object.
  • List<?>: a list of some unknown type. You can read Objects and call methods that don’t depend on the type, such as size() or clear().

![Diagram: List and List are unrelated, but both are subtypes of List<? extends Number>; List and List are subtypes of List<? super Integer>; both wildcard types are subtypes of List<?>](/assets/images/java-generics/variance.svg)

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

sum(List.of(1, 2, 3));      // List<Integer>: fine
sum(List.of(1.5, 2.5));     // List<Double>: fine

PECS: producer extends, consumer super

Joshua Bloch’s mnemonic tells you which wildcard each parameter needs: if a parameter produces values that you read, use extends; if it consumes values that you write, use super. The JDK’s Collections.copy is the textbook example:

public static <T> void copy(List<? super T> dest, List<? extends T> src)

src produces Ts and dest consumes them. With T as Number, you can copy a List<Integer> into a List<Object>, which a signature of copy(List<T>, List<T>) would reject.

![Diagram: Collections.copy reads Numbers out of a List<? extends Number> such as a List, and writes them into a List<? super Number> such as a List](/assets/images/java-generics/pecs.svg)

Once you know the rule, you see it all over the JDK:

Signature Produces (extends) Consumes (super)
Stream<T>.map(Function<? super T, ? extends R>) The function’s result The function’s input
List<E>.sort(Comparator<? super E>)   The comparator takes Es
Collection<E>.addAll(Collection<? extends E>) The argument hands over Es  
Map<K, V>.computeIfAbsent(K, Function<? super K, ? extends V>) The new value The key

A Comparator<Object> that compares by toString() can sort a List<String> only because sort takes Comparator<? super E>. Getting this right in your own APIs is the difference between a library that “just works” and one whose users write casts.

The same rule gives the bound you see on well-written comparison methods: <T extends Comparable<? super T>>. It matters more than it looks. LocalDate does not implement Comparable<LocalDate>; it implements Comparable<ChronoLocalDate>. Our earlier max(List<T>) with T extends Comparable<T> rejects a List<LocalDate>, while the ? super T version accepts it.

Wildcard capture

Sometimes a method that takes a wildcard needs to put back what it took out:

static void swap(List<?> list, int i, int j) {
    list.set(i, list.set(j, list.get(i)));   // compile error
}

The compiler knows the list holds some specific type, which it calls a capture (CAP#1 in error messages), but it cannot prove that the value you read is that same type when you write it back. The fix is a private generic helper that gives the unknown type a name:

public static void swap(List<?> list, int i, int j) { swapHelper(list, i, j); }

private static <T> void swapHelper(List<T> list, int i, int j) {
    list.set(i, list.set(j, list.get(i)));   // fine: T names the captured type
}

The public API stays simple, and the helper does the type-checked work.

Type erasure: what actually runs

The single most important fact about Java generics is that they exist only at compile time. The compiler checks your types and then erases them: each type parameter is replaced by its first bound (Object if it has none), and the compiler inserts casts wherever values come out.

![Diagram: the source code uses List; after javac checks and erases it, the JVM runs code with a raw List and a compiler-inserted (String) cast](/assets/images/java-generics/erasure.svg)

Java chose erasure for migration compatibility. When generics arrived in Java 5 in 2004, there were millions of lines of code using raw List and Map, and libraries could not all upgrade on the same day. With erasure, a generic ArrayList<E> compiles to the same class file shape as the old ArrayList, so old and new code can call each other in both directions without recompiling. C# made the other choice in .NET 2.0: it changed the runtime so that List<int> and List<string> are distinct types at runtime, at the cost of a clean break with older collections.

Erasure explains almost every “why can’t I…?” question about Java generics:

You cannot Because at runtime
Write new T() or new T[10] There is no T to construct
Test obj instanceof List<String> Every List is just a List
Overload f(List<String>) and f(List<Integer>) Both erase to f(List), a name clash
Use T in a static field or method of Box<T> One Box class is shared by every Box<X>
Write List<int> Type arguments must be reference types
Catch a T, or write a generic subclass of Throwable catch needs a runtime type to match

What erasure keeps

Erasure removes type arguments from objects and instructions, not from declarations. The compiler records the generic signatures of classes, fields and methods in a Signature attribute in the class file, and reflection can read them:

class UserRepo implements Repository<User, Long> { ... }

Type t = UserRepo.class.getGenericInterfaces()[0];
// t is a ParameterizedType: Repository<User, Long>

This is how dependency injection frameworks match a Repository<User, Long> to an injection point, and how Jackson’s TypeReference and Gson’s TypeToken capture a full generic type. The trick, known as a super type token, is an anonymous subclass:

List<Order> orders = mapper.readValue(json, new TypeReference<List<Order>>() {});

The {} creates a class whose declared supertype is TypeReference<List<Order>>. That declaration survives erasure, so the library can read List<Order> back at runtime. A plain List.class could only ever say List.

Bridge methods

Erasure sometimes leaves the compiler a gap to patch:

class Version implements Comparable<Version> {
    public int compareTo(Version other) { ... }
}

After erasure, Comparable declares compareTo(Object), but Version only defines compareTo(Version). So the compiler quietly generates a bridge method:

// synthetic, generated by javac
public int compareTo(Object other) { return compareTo((Version) other); }

You will meet bridges in stack traces, in reflection (Method.isBridge()), and in puzzling ClassCastExceptions when raw-typed code passes the wrong object into one.

Raw types and unchecked warnings

A raw type is a generic type used without type arguments, such as List instead of List<String>. Raw types exist only for compatibility with pre-generics code. Using one switches off generic checking for everything it touches, and the compiler tells you so with unchecked warnings.

Treat every unchecked warning as a possible ClassCastException. When you have proved a cast safe, and only then, suppress the warning on the smallest possible scope, usually one variable declaration, with a comment saying why:

// Safe: elements only ever holds values passed to add(E)
@SuppressWarnings("unchecked")
E result = (E) elements[index];

That is essentially what ArrayList does: it keeps its items in an Object[] and casts on the way out.

List<Object> and List<?> are not raw types. They are fully checked, and List<?> is the right way to say “a list of something; I don’t care what”.

The mistakes everyone makes

  • Using raw types. List list = new ArrayList(); turns off checking for everything it touches. Use List<Object> or List<?>.
  • Combining var with the diamond. var list = new ArrayList<>(); infers ArrayList<Object>. With var, give the type argument on the right: new ArrayList<String>().
  • Expecting a List<Dog> to be a List<Animal>. It isn’t. If you only read from it, take a List<? extends Animal>.
  • Returning wildcard types. A method that returns List<? extends Animal> forces every caller to deal with the wildcard. Use wildcards in parameters and return concrete types.
  • Using a type parameter once. If T appears only once in a signature, a wildcard says the same thing more simply: void printAll(List<?> items) rather than <T> void printAll(List<T> items).
  • Suppressing warnings on a whole class. @SuppressWarnings("unchecked") on a class also hides the next real bug. Put it on one declaration, with a comment.
  • Fighting inference on chained calls. Comparator.comparing(p -> p.name()).reversed() fails to compile, because the lambda’s type is not known when reversed() is resolved. Use a method reference, Comparator.comparing(Person::name).reversed(), or a type witness.

Advanced ideas

Heap pollution

Heap pollution is when a variable of a parameterised type refers to an object that is not of that type. It can only happen through unchecked operations: raw types, unchecked casts or generic varargs. What makes it dangerous is where the failure appears:

List<String> strings = new ArrayList<>();
List raw = strings;          // raw type: unchecked from here on
raw.add(42);                 // a warning, but it compiles and runs

// ... far away, perhaps in another module ...
String s = strings.get(0);   // ClassCastException here

The bug is on the third line, but the exception is thrown at a cast the compiler inserted on the last line, which may be thousands of lines and several modules away. The stack trace points to the victim, not the culprit. When you suspect heap pollution, wrap the collection with Collections.checkedList(list, String.class), which checks every insert at runtime and fails on the line that adds the wrong item.

The Java Language Specification makes a precise promise here: if your whole program compiles without unchecked warnings, the casts the compiler inserts will never fail. Every suppressed warning is a hole in that promise that you have personally signed off.

Generic varargs and @SafeVarargs

Varargs are arrays, and Java cannot create a true array of a type such as List<String>. When you write a generic varargs method, the compiler creates an array of the erased type anyway and warns about possible heap pollution:

static void dangerous(List<String>... lists) {
    Object[] array = lists;
    array[0] = List.of(42);          // heap pollution
    String s = lists[0].get(0);      // ClassCastException
}

A generic varargs method is safe if it never stores anything into the varargs array and never lets the array escape to code you don’t control. When both hold, annotate it with @SafeVarargs (allowed on static, final and private methods), as List.of(E...) and Arrays.asList(T...) do. In particular, never return the varargs array. A method like <T> T[] toArray(T... args) { return args; } looks harmless, but when called from another generic method it returns an Object[] that the caller believes is a String[].

Generic arrays

Because new T[n] is illegal, generic code that needs an array has three options:

  • Store an Object[] and cast elements on the way out, as ArrayList does.
  • Ask the caller for a Class<T> and use Array.newInstance(type, n).
  • Ask for an array constructor, as Collection.toArray(IntFunction<T[]>) has done since Java 11: list.toArray(String[]::new).

In APIs, prefer List<T> to T[]. Lists are invariant and checked at compile time; arrays are covariant and checked at runtime, and the two mix badly.

Type tokens and typesafe heterogeneous containers

A Class<T> object is a runtime value that also carries a compile-time type, which makes it a type token. That lets you build a map whose values have different types, each one tied to its key:

public final class Context {
    private final Map<Class<?>, Object> values = new HashMap<>();

    public <T> void put(Class<T> type, T value) {
        values.put(Objects.requireNonNull(type), type.cast(value));
    }

    public <T> T get(Class<T> type) {
        return type.cast(values.get(type));
    }
}

context.put(User.class, currentUser);
User user = context.get(User.class);   // no cast in your code, fully checked

type.cast checks at runtime, so even a caller using raw types cannot pollute the map. The limitation is that a Class cannot describe List<String>, only List. When you need the full generic type at runtime, use a super type token instead.

Recursive bounds and self types

Enum is declared as Enum<E extends Enum<E>>. The bound reads “E is an enum of itself”, and it is what lets compareTo(E) accept only constants of the same enum. The same trick, sometimes called the curiously recurring template pattern, lets builder hierarchies return the right subclass from inherited methods:

abstract class Builder<B extends Builder<B>> {
    protected String name;
    B name(String name) { this.name = name; return self(); }
    protected abstract B self();
}

final class PizzaBuilder extends Builder<PizzaBuilder> {
    PizzaBuilder cheese() { /* ... */ return this; }
    @Override protected PizzaBuilder self() { return this; }
}

new PizzaBuilder().name("Margherita").cheese();   // name() returns PizzaBuilder

Without the recursive bound, name() would return Builder, and the chain would lose cheese(). Java cannot enforce that B really is the subclass itself (class Odd extends Builder<PizzaBuilder> compiles), so the pattern is a convention, which is why the self() method exists rather than a cast of this.

Generic exceptions and the sneaky throw

You cannot catch a type parameter, but you can throw one, which lets functional interfaces carry checked exceptions:

@FunctionalInterface
interface ThrowingSupplier<T, X extends Exception> {
    T get() throws X;
}

The same machinery has a darker use. When a thrown type variable has no other constraints, Java 8 and later infer it as RuntimeException, so this compiles without any throws clause at the call site:

@SuppressWarnings("unchecked")
static <X extends Throwable> RuntimeException sneakyThrow(Throwable t) throws X {
    throw (X) t;   // erased to Throwable: no real cast, the checked exception escapes
}

Lombok’s @SneakyThrows relies on the same effect. Recognise it in code review: it means a checked exception can leave a method that does not declare it, which silently breaks callers who rely on catch (IOException e).

Generics with records, sealed types and patterns

Modern Java combines generics with sealed types to express “one of these shapes” cleanly:

sealed interface Result<T> permits Ok, Err {}
record Ok<T>(T value) implements Result<T> {}
record Err<T>(Exception error) implements Result<T> {}

static <T> T unwrap(Result<T> result) {
    return switch (result) {
        case Ok<T>(var value) -> value;
        case Err<T>(var error) -> throw new IllegalStateException(error);
    };
}

The switch needs no default, because the compiler knows Ok and Err are the only implementations. Since Java 21, the compiler can also infer the type arguments of record patterns, so case Ok(var value) works too. Patterns still respect erasure: obj instanceof List<String> is rejected when obj is an Object, but allowed when the compiler can prove it safe, such as collection instanceof List<String> list where collection is a Collection<String>.

Erasure and binary compatibility

The JDK’s Collections.max has a signature that looks redundant:

public static <T extends Object & Comparable<? super T>> T max(Collection<? extends T> coll)

The Object & is there because a type parameter erases to its first bound. Without it, max would erase to return Comparable; with it, it erases to return Object, exactly matching the method’s signature before generics, so code compiled against Java 1.4 kept linking. This matters for any library you publish:

  • Changing only type arguments in a signature, say from List<String> to List<CharSequence>, changes only the Signature metadata. It is binary compatible, although it may break callers’ source code.
  • Changing a bound, or anything else that changes the erasure, changes the method descriptor the JVM links against. Callers compiled against the old version fail with NoSuchMethodError until they are rebuilt.

Intersection types like Object & Comparable<? super T> can also appear in casts. That is how you make a lambda serialisable: (Runnable & Serializable) () -> doWork().

What generics cost at runtime

Erasure means there is one compiled copy of each generic class, so type arguments themselves cost nothing at runtime. The inserted casts are checkcast instructions, which are cheap and often removed entirely by the JIT compiler. The real cost is boxing. Type arguments must be reference types, so a List<Integer> stores pointers to separate Integer objects:

Storing one million ints Approximate memory Layout
int[] 4 MB Values side by side
ArrayList<Integer> 20 MB A 4-byte reference plus a 16-byte Integer object per value

On top of the memory, every read chases a pointer to a different place in memory. That is why the JDK has IntStream, IntFunction and ToIntFunction, and why libraries such as fastutil and Eclipse Collections offer primitive collections. On hot paths with primitive data, avoid generic containers. Project Valhalla is working towards value classes and, later, generics that can be specialised for them, which would let a generic list store values flat; until that ships, primitive specialisations remain the answer.

How Java compares

Language Generic types at runtime Where variance is declared
Java Erased At each use (? extends T, ? super T)
Kotlin Erased on the JVM, except reified parameters of inline functions On the declaration (out T, in T) or at each use
Scala Erased on the JVM On the declaration (+T, -T)
C# Reified; List<int> gets specialised code On the declaration, for interfaces and delegates (out T, in T)
C++ Templates generate separate code for each type No variance

Kotlin’s out T on a class says “this type only ever produces T”, which is PECS decided once by the author instead of at every call site. Java’s use-site wildcards are more flexible and much noisier. It is worth knowing both when you design a Java API that Kotlin code will call: Kotlin sees List<? extends T> as List<out T>.

Where the type system stops

  • No higher-kinded types. You cannot write a method that is generic over “any container F<T>”. That is why Stream, Optional and CompletableFuture each define their own map and flatMap, and Java code cannot abstract over all three.
  • Inference has limits. Inference works on one expression at a time and looks only at the immediate target type, which is why the chained Comparator call above needed help.
  • Soundness has known holes. In 2016, Nada Amin and Ross Tate published a short program that combined wildcards and null to make Java throw a ClassCastException with no casts and no warnings. Such programs don’t appear in real code by accident, but they are a reminder that the type system is a strong guarantee in practice, not a mathematical proof.

How to design generic APIs

Situation Do this
A parameter you only read from ? extends T
A parameter you only write to ? super T
A parameter you read and write Plain T
Items that must be comparable <T extends Comparable<? super T>>
A type parameter used only once ? instead
Return types Concrete types, never wildcards
The type is needed at runtime A Class<T> token or a super type token
An array of T is needed Object[] internally, IntFunction<T[]> in the API
A generic varargs method @SafeVarargs, and never store into or expose the array
A cast you have proved safe @SuppressWarnings("unchecked") on one declaration, with a comment
Hot paths over primitive data Primitive specialisations such as int[] and IntStream
A published library Keep erasures stable, or callers fail with NoSuchMethodError

Key takeaways

  • Generics move type errors from runtime to compile time, and let one piece of code work safely with many types.
  • Generic types are invariant: a List<Integer> is not a List<Number>. Wildcards add flexibility, and PECS tells you which one: producer extends, consumer super.
  • Generics are erased. The JVM sees raw types and compiler-inserted casts, which explains why you cannot write new T(), instanceof List<String> or List<int>.
  • Declarations survive erasure, which is what makes super type tokens and framework injection work.
  • Every unchecked warning is a possible ClassCastException thrown far from its cause. Eliminate them, or prove them safe and suppress them narrowly.
  • For library authors, erasure is the binary contract: change a bound and already-compiled callers break.

Further reading

  • Joshua Bloch, Effective Java (3rd edition), chapter 5, “Generics” (items 26 to 33)
  • Maurice Naftalin and Philip Wadler, Java Generics and Collections (O’Reilly)
  • Angelika Langer, “Java Generics FAQs”
  • Oracle, “The Java Tutorials: Generics” trail
  • The Java Language Specification, sections 4.5 to 4.8 (parameterised types, erasure, reifiable and raw types) and chapter 18 (type inference)
  • Nada Amin and Ross Tate, “Java and Scala’s Type Systems are Unsound: The Existential Crisis of Null Pointers”, OOPSLA 2016