Skip to content
SparkTime, home
Java From Zero to Job-Ready
Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 14 of 41

Lesson 14 · Collections and Generics

Generics: Type Safety, Wildcards and Erasure

What the angle brackets mean and why they exist. Generic classes and methods, bounded types, why List of Integer is not a List of Number, wildcards with extends and super, the PECS rule that makes them make sense, and type erasure with its practical consequences.

On this page

Key takeaways

  • Generics let a class or method work with many types while the compiler checks type safety, which removes casts and turns many runtime ClassCastExceptions into compile errors.
  • Generic types are invariant: List<Integer> is not a List<Number>. Use wildcards to express flexibility, with extends when you only read from a structure and super when you only write to it (PECS).
  • A bounded type parameter such as T extends Comparable<T> lets generic code call the bound's methods.
  • Type erasure removes type arguments at compile time, so at runtime List<String> and List<Integer> are the same class. That is why you cannot create a T, test instanceof with a parameterised type or create generic arrays.
  • Keep generic APIs simple. Avoid wildcards in return types, avoid raw types, and prefer a clear named type over a tangle of type parameters.

Before Java 5, a collection held plain Objects. You could put anything in, and when you took something out you had to cast it back to the type you hoped it was. If the hope was wrong, the program failed at runtime, often far from the line that put the wrong thing in. Generics were added in Java 5 to let the compiler check these things for you.

You have been using generics since the collections articles: List<String> is a list that holds only strings. This article explains how that works, how to write your own generic code, and the part that confuses almost everyone, wildcards.

What you will learn

#
  • What problem generics solve.
  • How to write generic classes and methods.
  • What bounded type parameters do.
  • Why generic types are invariant, and how wildcards and PECS fix that.
  • What type erasure is, and what it forbids.
  • How to keep generic code readable.

The problem: collections of Object

#

Here is a list used the older style, before generics, with a raw type (a generic class used without its type argument):

Java
// File: Main.javaimport java.util.ArrayList;import java.util.List;public class Main {    @SuppressWarnings({"rawtypes", "unchecked"})    public static void main(String[] args) {        List names = new ArrayList();          // raw: holds any Object        names.add("Asha");        names.add("Ravi");        names.add(42);                          // compiles: nothing stops it        for (Object item : names) {            String name = (String) item;        // cast needed, and unsafe            System.out.println(name.toUpperCase());        }    }}
Code
ASHARAVIException in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')

It compiled (the @SuppressWarnings hides the compiler's warnings that something unsafe is happening), printed two names, and then failed on the third element with a ClassCastException. The bug was the line names.add(42), but the failure surfaced later, in different code. Generics move that failure to the line that causes it, and make it a compile-time error:

Java
List<String> names = new ArrayList<>();names.add("Asha");names.add(42);          // compile error: incompatible typesString first = names.get(0);   // no cast needed

Generics give you two things: type safety (the compiler rejects wrong types) and no casts (the compiler knows what comes out). The <> after new ArrayList is called the diamond: the compiler infers the type argument from the left-hand side.

Writing a generic class

#

A type parameter is a placeholder for a type, written in angle brackets after the class name. By convention the names are single capital letters: T (type), E (element), K (key), V (value), R (result).

Java
// File: Pair.javapublic class Pair<A, B> {    private final A first;    private final B second;    public Pair(A first, B second) {        this.first = first;        this.second = second;    }    public A first() {        return first;    }    public B second() {        return second;    }    public Pair<B, A> swapped() {        return new Pair<>(second, first);    }    @Override    public String toString() {        return "(" + first + ", " + second + ")";    }}
Java
// File: Main.javaimport java.util.List;public class Main {    // A generic method: the type parameter <T> is declared before the return type    static <T> T firstOrDefault(List<T> list, T fallback) {        return list.isEmpty() ? fallback : list.get(0);    }    // A bounded type parameter: T must be comparable to itself    static <T extends Comparable<T>> T largest(List<T> list) {        T best = list.get(0);        for (T item : list) {            if (item.compareTo(best) > 0) {                best = item;            }        }        return best;    }    public static void main(String[] args) {        Pair<String, Integer> entry = new Pair<>("age", 30);        String key = entry.first();            // no cast: the compiler knows A is String        int value = entry.second();        System.out.println(key + "=" + value + " " + entry.swapped());        System.out.println(firstOrDefault(List.of("x", "y"), "none"));        System.out.println(firstOrDefault(List.<Integer>of(), -1));        System.out.println(largest(List.of(3, 9, 4)) + " " + largest(List.of("pear", "apple", "zebra")));    }}
Code
age=30 (30, age)x-19 zebra

A generic class (Pair<A, B>) declares type parameters that its fields and methods can use. A generic method (firstOrDefault) declares its own type parameter just before its return type, and the compiler usually infers it from the arguments, so you rarely write it out. (Where it cannot, as with an empty List.of(), you can give it explicitly: List.<Integer>of().)

Bounded type parameters

#

largest needs to compare items, so it cannot accept just any T. The bound T extends Comparable<T> says that T must be some type that can be compared to itself, and inside the method you may therefore call compareTo. Without the bound, item.compareTo(best) would not compile, because a plain T is only known to be an Object. Here extends means "is a subtype of", and it is used for interfaces as well as classes. You can combine bounds with & (T extends Number & Comparable<T>).

Generics are invariant

#

Here is the part that surprises people. An Integer is a Number. Is a List<Integer> a List<Number>? Intuition says yes. The compiler says no:

Java
// File: Main.javaimport java.util.ArrayList;import java.util.List;public class Main {    public static void main(String[] args) {        List<Integer> integers = new ArrayList<>();        List<Number> numbers = integers;        numbers.add(3.14);                    // would put a Double into a list of Integers    }}
Code
Main.java:8: error: incompatible types: List<Integer> cannot be converted to List<Number>        List<Number> numbers = integers;                               ^1 error

The rule is that List<Integer> is not a subtype of List<Number>, even though Integer is a subtype of Number. This is called invariance, and the reason is exactly what the example shows: if the assignment were allowed, numbers.add(3.14) would put a Double into a list that everybody else believes contains only Integers. The type system would be broken.

Arrays behave differently, and worse: they are covariant. Integer[] is a Number[], so the same trick compiles with arrays and fails at runtime with an ArrayStoreException. Generics chose to be strict at compile time. But strictness has a cost: how do you write a method that sums any list of numbers, whatever its exact element type?

Wildcards

#

The answer is the wildcard, written ?, meaning "some unknown type". You constrain it with a bound to say what you know about it.

Syntax

Meaning

You can...

List<?>

A list of unknown type

Read elements as Object; add nothing (except null)

List<? extends Number>

A list of Number or some subtype

Read elements as Number; add nothing

List<? super Integer>

A list of Integer or some supertype

Write Integers; reading gives only Object

Why can you read from ? extends Number but not write to it? The list could be a List<Integer>, a List<Double> or a List<Number>, and the compiler does not know which. Reading is safe, because every element is at least a Number. Writing is unsafe, because adding a Double might break a List<Integer>. For ? super Integer, it is the other way round: the list could be List<Integer>, List<Number> or List<Object>, and an Integer can safely be put in any of them, but what comes out might be any supertype, so you can only treat it as Object.

PECS: Producer Extends, Consumer Super

#

Remembering which to use is easy with the mnemonic PECS: Producer Extends, Consumer Super.

  • If a parameter produces values that your method reads (it is a source, an "in" variable), use ? extends T.
  • If a parameter consumes values that your method writes (it is a destination, an "out" variable), use ? super T.
  • If it does both, do not use a wildcard.

Oracle's tutorial states the same guideline in terms of "in" and "out" variables, and adds one more: avoid wildcards in return types, because it forces callers to deal with them.

Java
// File: Main.javaimport java.util.ArrayList;import java.util.List;public class Main {    // Producer: we only READ numbers from the list    static double sum(List<? extends Number> numbers) {        double total = 0;        for (Number n : numbers) {            total += n.doubleValue();        }        return total;    }    // Consumer: we only WRITE integers into the list    static void fillWithSquares(List<? super Integer> target, int count) {        for (int i = 1; i <= count; i++) {            target.add(i * i);        }    }    // Both: copy from a producer into a consumer of the same element type T    static <T> void copy(List<? extends T> source, List<? super T> destination) {        for (T item : source) {            destination.add(item);        }    }    public static void main(String[] args) {        List<Integer> integers = List.of(1, 2, 3);        List<Double> doubles = List.of(1.5, 2.5);        System.out.println(sum(integers) + " " + sum(doubles));   // one method, many list types        List<Number> numbers = new ArrayList<>();        List<Object> objects = new ArrayList<>();        fillWithSquares(numbers, 3);        fillWithSquares(objects, 2);        System.out.println(numbers + " " + objects);        List<Number> mixed = new ArrayList<>();        copy(integers, mixed);        copy(doubles, mixed);        System.out.println(mixed);    }}
Code
6.0 4.0[1, 4, 9] [1, 4][1, 2, 3, 1.5, 2.5]

sum accepts a list of integers and a list of doubles, which would be impossible with List<Number> because of invariance. fillWithSquares accepts a List<Number> and a List<Object>, both of which can hold an Integer. And copy is the classic combination: the source may be any list of T or a subtype, and the destination any list of T or a supertype. This is the shape of Collections.copy, Collections.addAll and many methods in the standard library. When you see ? extends or ? super in a library signature, apply PECS and you can read it.

Type erasure

#

Generics are a compile-time feature. Oracle's tutorial describes how the compiler implements them: it replaces every type parameter with its bound (or Object if it has none), inserts casts where needed, and generates bridge methods to preserve polymorphism. The resulting bytecode contains only ordinary classes, interfaces and methods. This is called type erasure, and it was done so that generic code stays compatible with code written before generics, with no runtime overhead and no new JVM.

Java
// File: Main.javaimport java.util.ArrayList;import java.util.List;public class Main {    static class Box<T extends Comparable<T>> {        T value;    }    public static void main(String[] args) throws Exception {        List<String> strings = new ArrayList<>();        List<Integer> integers = new ArrayList<>();        System.out.println(strings.getClass() == integers.getClass());        System.out.println(strings.getClass().getSimpleName());        // After erasure, the field's type is its bound: Comparable        System.out.println(Box.class.getDeclaredField("value").getType().getSimpleName());    }}
Code
trueArrayListComparable

At runtime, a List<String> and a List<Integer> are the same class, and the T in Box has become its bound, Comparable. The type arguments exist only in the source and in the compiler's checks. Several restrictions follow directly:

You cannot...

Because

new T() or new T[10]

The runtime does not know what T is

x instanceof List<String>

At runtime there is only List. (x instanceof List<?> is allowed)

Overload by type argument alone: f(List<String>) and f(List<Integer>)

Both erase to f(List) and clash

Use primitives as type arguments: List<int>

Type arguments must be reference types, so use List<Integer>

Create an array of a parameterised type: new List<String>[3]

Arrays check element types at runtime, and erasure would defeat that

Catch or throw a generic exception type

The runtime cannot tell them apart

Java
// File: Main.javaimport java.util.List;public class Main {    static void print(List<String> list) {        System.out.println("strings");    }    static void print(List<Integer> list) {        System.out.println("integers");    }    public static void main(String[] args) {        print(List.of("a"));    }}
Code
Main.java:9: error: name clash: print(List<Integer>) and print(List<String>) have the same erasure    static void print(List<Integer> list) {                ^1 error

The compiler reports that both methods have the same erasure, so they cannot coexist. When you need the runtime to know a type, a common workaround is to pass a Class<T> object as a parameter (Class<T> type), which is a real value that survives erasure. You will meet this pattern in frameworks.

Erasure also explains the odd corners of raw types and the warnings that mention "unchecked". Raw types exist for backward compatibility. Treat every unchecked warning as something to understand, and never as noise to suppress.

A generic abstraction you will meet again

#

Generics are most valuable when you write reusable infrastructure. Here is a tiny version of the repository idea that Spring Data uses (article 34): an interface parameterised by the entity type and its identifier type.

Java
// File: Repository.javaimport java.util.List;import java.util.Optional;public interface Repository<T, ID> {    T save(T entity);    Optional<T> findById(ID id);    List<T> findAll();    void deleteById(ID id);}
Java
// File: InMemoryRepository.javaimport java.util.ArrayList;import java.util.LinkedHashMap;import java.util.List;import java.util.Map;import java.util.Optional;import java.util.function.Function;public class InMemoryRepository<T, ID> implements Repository<T, ID> {    private final Map<ID, T> store = new LinkedHashMap<>();    private final Function<T, ID> idOf;           // how to get an entity's id    public InMemoryRepository(Function<T, ID> idOf) {        this.idOf = idOf;    }    @Override    public T save(T entity) {        store.put(idOf.apply(entity), entity);        return entity;    }    @Override    public Optional<T> findById(ID id) {        return Optional.ofNullable(store.get(id));    }    @Override    public List<T> findAll() {        return new ArrayList<>(store.values());    }    @Override    public void deleteById(ID id) {        store.remove(id);    }}
Java
// File: Main.javapublic class Main {    record Company(long id, String name) {}    public static void main(String[] args) {        Repository<Company, Long> companies = new InMemoryRepository<>(Company::id);        companies.save(new Company(1, "Acme"));        companies.save(new Company(2, "Globex"));        System.out.println(companies.findById(2L).map(Company::name).orElse("missing"));        System.out.println(companies.findById(9L).isPresent());        companies.deleteById(1L);        System.out.println(companies.findAll());    }}
Code
Globexfalse[Company[id=2, name=Globex]]

One interface and one implementation now serve any entity type. The compiler guarantees that companies.save(...) accepts only Company and that findById takes a Long. (Optional and the Function parameter are covered in article 17 and article 15.)

Keeping generic code readable

#
  • Use generics where they remove casts or duplication, not to show cleverness. A method with three type parameters and nested wildcards is rarely the best design.
  • Apply PECS to parameters of reusable methods. In application code, you can often use plain List<Employee>.
  • Do not return wildcard types. Return a concrete List<T>.
  • Never use raw types in new code.
  • Name type parameters meaningfully when there are several (<K, V>, <ID, ENTITY>).
  • Understand unchecked warnings instead of silencing them. If you must suppress one, do it on the smallest scope with a comment saying why it is safe.

Common mistakes

#
  • Using raw types such as List or Map.
  • Expecting List<Integer> to be usable as List<Number>.
  • Using extends where you need super (and getting a compile error when adding).
  • Writing new T() and being surprised.
  • Overloading methods that differ only in type arguments.
  • Suppressing unchecked warnings without understanding them.
  • Returning wildcard types, which makes the API awkward for callers.
  • Using Object and casts when a generic would be safer.

Exercises

#

Exercise 1

Predict

#

Which of these compile? (a) List<Object> a = new ArrayList<String>(); (b) List<? extends Object> b = new ArrayList<String>(); (c) List<?> c = new ArrayList<Integer>(); Then say whether you can add a string to (b).

Exercise 2: Write a generic method

#

Write static <T> boolean allMatch(List<T> items, Predicate<T> test)-style logic without using streams: return true only if every element satisfies the test. Then explain whether the signature would be better with ? super T for the predicate.

Exercise 3: PECS

#

A method static void addAll(Collection<? ___ T> from, Collection<? ___ T> to) copies elements. Fill in extends or super for each blank, and explain.

Exercise 4: Bounded type

#

Write static <T extends Number> double average(List<T> numbers) and explain why T extends Number is needed.

Exercise 5: Erasure

#

Why does static <T> T[] makeArray(int n) { return new T[n]; } not compile, and what is a common alternative?

Show solutionHide solution

(a) Does not compile: List<String> is not a List<Object> (invariance). (b) Compiles: a List<String> is a List<? extends Object>. (c) Compiles: List<?> accepts any list. You cannot add a string to (b): the compiler only knows the list holds some subtype of Object, not which one, so adding anything except null is rejected.

Exercise 2

Show solutionHide solution
Java
static <T> boolean allMatch(List<T> items, Predicate<? super T> test) {    for (T item : items) {        if (!test.test(item)) {            return false;        }    }    return true;}

The predicate consumes items of type T, so by PECS it should be Predicate<? super T>: a Predicate<Object> (which can test any object) can then be used on a List<String>. With plain Predicate<T> that call would not compile.

Exercise 3

Show solutionHide solution

static <T> void addAll(Collection<? extends T> from, Collection<? super T> to). The source from produces elements (we read from it), so it is extends. The destination to consumes elements (we write to it), so it is super. This lets you copy a List<Integer> into a List<Number> or List<Object>.

Exercise 4

Show solutionHide solution
Java
static <T extends Number> double average(List<T> numbers) {    double total = 0;    for (T n : numbers) {        total += n.doubleValue();    }    return numbers.isEmpty() ? 0 : total / numbers.size();}

The bound is needed because the method calls doubleValue(), which exists on Number. Without the bound, T is only known to be an Object, which has no such method. (List<? extends Number> would also work, as in the sum example, and is often simpler when the type parameter is not needed anywhere else.)

Exercise 5

Show solutionHide solution

After erasure, T is just Object, so the runtime cannot create an array of the right element type, and the compiler refuses. Common alternatives: use a List<T> instead of an array, accept a Class<T> and create the array with java.lang.reflect.Array.newInstance(type, n), or accept an IntFunction<T[]> generator such as String[]::new (which is what Stream.toArray does).

Where to go next

#

Sources & further reading

  1. docs.oracle.com
  2. docs.oracle.com
  • Guide · Technology

    Heaps and Priority Queues

  • Guide · Technology

    Dynamic Programming Practice: From 1D to 2D

  • Course · Technology

    DSA From Zero to Job-Ready: A Practical Guide with Java