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
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
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.
For
Learners who use List<String> and Map<K, V> daily and want to understand how generics work and how to write generic code.
Level
Before you start
Interfaces, Polymorphism and Composition (article 6) and the collections articles (11 to 13).
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):
// 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()); } }}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:
List<String> names = new ArrayList<>();names.add("Asha");names.add(42); // compile error: incompatible typesString first = names.get(0); // no cast neededGenerics 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).
// 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 + ")"; }}// 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"))); }}age=30 (30, age)x-19 zebraA 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:
// 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 }}Main.java:8: error: incompatible types: List<Integer> cannot be converted to List<Number> List<Number> numbers = integers; ^1 errorThe 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... |
|---|---|---|
| A list of unknown type | Read elements as |
| A list of | Read elements as |
| A list of | Write |
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.
// 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); }}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.
// 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()); }}trueArrayListComparableAt 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 |
|---|---|
| The runtime does not know what |
| At runtime there is only |
Overload by type argument alone: | Both erase to |
Use primitives as type arguments: | Type arguments must be reference types, so use |
Create an array of a parameterised type: | Arrays check element types at runtime, and erasure would defeat that |
Catch or throw a generic exception type | The runtime cannot tell them apart |
// 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")); }}Main.java:9: error: name clash: print(List<Integer>) and print(List<String>) have the same erasure static void print(List<Integer> list) { ^1 errorThe 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.
// 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);}// 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); }}// 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()); }}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
ListorMap. - Expecting
List<Integer>to be usable asList<Number>. - Using
extendswhere you needsuper(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
Objectand 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
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
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
#- Next: Lambdas, Functional Interfaces and Method References begins the functional module, built on generic functional interfaces.
- For generics in the framework you will use: JPA and Hibernate: The Persistence Model.
- For the interview version: Interview Concepts, Part 1.
Sources & further reading
- Technology
- Software Engineering
- #generics
- #interview preparation
- #Java
- #Java backend
- #Java collections
- #Java interview
- #JPA
- #learn Java
- #Spring Boot
- #SQL
Connected lessons
References
Linked from 2 guides
Other pages in the library that point here.
Keep reading
More in Technology →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

