Generics let the compiler check what goes into a list, a box or a method, and then erase that information before the program runs. Learn bounds, wildcards, PECS and how to read a JDK signature by running small programs.
Generics let you write List<String> instead of a list of anything, so the compiler checks what goes in and you never cast what comes out. They’re also why some perfectly reasonable code won’t compile: you can’t write new T(), and a List<Integer> isn’t a List<Number>. Both rules make sense once you see what the compiler does with the type after it has checked it.
This post covers the bug generics fixed, generic classes, records and methods, bounded types, type erasure, wildcards and the PECS rule, generic interfaces, primitives, and how to read a signature like Collections.max. Every program below was run on Java 25, and its output is pasted from the run. To run one yourself, save it as Main.java and run java Main.java.
Why generics: a list of Object fails late
Before Java 5, a List held Object, so you could put anything in and had to cast everything that came out. Here’s that style, still legal today:
@SuppressWarnings({"rawtypes", "unchecked"})
void main() {
List names = new ArrayList();
names.add("Ada");
names.add(42);
for (Object o : names) {
String name = (String) o;
IO.println(name.toUpperCase());
}
}
It prints, then stops:
ADA
Exception 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')
names.add(42) is the bug, but the program crashes two lines later, at the cast. In a real codebase the add and the cast can be in different files, written by different people, months apart. The exception tells you where the wrong value was found, not where it was put.
A List with no type argument is called a raw type. Every program in this series is built with javac -Xlint:all -Werror, and that’s why the @SuppressWarnings line is there. Take it out and the build stops:
void main() {
List names = new ArrayList();
names.add("Ada");
names.add(42);
IO.println(names.size());
}
The build fails with:
Main.java:2: warning: [rawtypes] found raw type: List
Main.java:2: warning: [rawtypes] found raw type: ArrayList
Main.java:3: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
Main.java:4: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
error: warnings found and -Werror specified
Without -Werror these are only warnings, and java Main.java runs the code anyway. Now give the list a type argument:
void main() {
List<String> names = new ArrayList<>();
names.add("Ada");
names.add(42);
for (String name : names) {
IO.println(name.toUpperCase());
}
}
The build fails with:
Main.java:4: error: incompatible types: int cannot be converted to String
names.add(42);
^
The error now points at the line that’s actually wrong, and nothing ever runs. The loop needs no cast, because the compiler already knows every element is a String. The empty <> on new ArrayList<>() is the diamond: the compiler copies the type argument from the left-hand side.
Generic classes and records
A generic class declares a type parameter in angle brackets, and each use of the class fills it in. T is a placeholder for a type, the way a method parameter is a placeholder for a value:
class Box<T> {
private T value;
Box(T value) {
this.value = value;
}
T get() {
return value;
}
void set(T value) {
this.value = value;
}
}
record Pair<A, B>(A first, B second) {}
void main() {
Box<String> label = new Box<>("fragile");
label.set("this side up");
IO.println(label.get().length());
var entry = new Pair<>("Ada", 36);
String name = entry.first();
int age = entry.second();
IO.println(name + " is " + age);
IO.println(entry);
}
It prints:
12
Ada is 36
Pair[first=Ada, second=36]
label.get() returns a String, so .length() works with no cast. Pair<A, B> has two parameters, and the record fills in both from the constructor arguments: Pair<String, Integer>. By convention type parameters are single capital letters: T for type, E for element, K and V for key and value.
Generic methods and type inference
A method can have its own type parameters, declared just before the return type. The compiler works out what they are from the arguments at each call. This is called type inference:
static <T> T firstOrDefault(List<T> items, T fallback) {
return items.isEmpty() ? fallback : items.getFirst();
}
void main() {
String city = firstOrDefault(List.of("Lisbon", "Pune"), "nowhere");
Integer score = firstOrDefault(List.of(), 0);
IO.println(city);
IO.println(score);
var mixed = List.<Number>of(1, 2.5);
mixed.forEach(n -> IO.println(n.doubleValue()));
}
It prints:
Lisbon
0
1.0
2.5
In the first call T is String. In the second, List.of() is empty, so the compiler takes T from the fallback 0 and makes it Integer.
List.<Number>of(1, 2.5) is an explicit type witness: you name T yourself instead of letting the compiler infer it. You rarely need one. We tried the same thing on our own method, Main.<String>firstOrDefault(...), and javac answered cannot find symbol for Main. A compact source file’s class has no name you can write in code, so you can’t put a witness on its static methods.
Bounded types: asking more of T
A plain T could be any type, so the only methods you can call on it are Object‘s. To call compareTo, you have to tell the compiler that T is something comparable. Here’s what happens if you don’t:
static <T> T max(List<T> items) {
T best = items.getFirst();
for (T item : items) {
if (item.compareTo(best) > 0) {
best = item;
}
}
return best;
}
void main() {
IO.println(max(List.of(3, 9, 4)));
}
The build fails with:
Main.java:4: error: cannot find symbol
if (item.compareTo(best) > 0) {
^
A bound fixes it. <T extends Comparable<T>> means “any T, as long as a T can be compared with another T“. extends is used for interfaces here too:
static <T extends Comparable<T>> T max(List<T> items) {
T best = items.getFirst();
for (T item : items) {
if (item.compareTo(best) > 0) {
best = item;
}
}
return best;
}
void main() {
IO.println(max(List.of(3, 9, 4)));
IO.println(max(List.of("pear", "apple", "plum")));
}
It prints:
9
plum
A type parameter can have several bounds, joined with &. The class, if there is one, comes first. <T extends Number & Comparable<T>> lets one method use doubleValue() from Number and compareTo from Comparable:
static <T extends Number & Comparable<T>> String summary(List<T> items) {
T best = items.getFirst();
double total = 0;
for (T item : items) {
total += item.doubleValue();
if (item.compareTo(best) > 0) {
best = item;
}
}
return "max " + best + ", total " + total;
}
void main() {
IO.println(summary(List.of(3, 9, 4)));
IO.println(summary(List.of(1.5, 0.25)));
}
It prints:
max 9, total 16.0
max 1.5, total 1.75
Keep this one in mind. Our max has a gap that we’ll hit at the end of the post, and Collections.max has a stranger signature because it doesn’t have that gap.
Type erasure: the type is checked, then removed
Java checks generic types when it compiles, then removes them. At run time a List<String> and a List<Integer> are both just ArrayList:
record Box<T>(T value) {}
record Ranked<T extends Comparable<T>>(T value) {}
void main() {
List<String> words = new ArrayList<>();
List<Integer> numbers = new ArrayList<>();
IO.println(words.getClass() == numbers.getClass());
IO.println(words.getClass().getName());
IO.println(Box.class.getRecordComponents()[0].getType());
IO.println(Ranked.class.getRecordComponents()[0].getType());
}
It prints:
true
java.util.ArrayList
class java.lang.Object
interface java.lang.Comparable
There’s one class object for all lists. Nothing at run time records whether a list was meant for strings or numbers. The last two lines show what’s left of T in the compiled record: a plain T becomes Object, and a bounded T becomes its first bound, Comparable. That replacement is called type erasure.
Explain it like I’m ten
A generic box is a lunchbox with a label on the lid: “sandwiches only”. When you pack it, a grown-up checks the label. Try to put a sock in and they stop you right there.
Once the lunchbox is packed, the label is torn off before it goes in your school bag. At lunchtime nobody checks the label, because there isn’t one. Nobody needs to either. The check already happened, so what’s inside is sandwiches.
That’s why every lunchbox in the bag looks the same. You can’t ask a lunchbox in the bag “were you a sandwich box?”, because the answer went in the bin with the label.
The precise version
javac type-checks every use of a generic type against its type arguments. Then it compiles the code with each type parameter replaced by its erasure, which is its first bound, or Object if it has none. Where your code reads a T and uses it as a String, javac inserts a hidden cast. The bytecode of ArrayList is one class, shared by every ArrayList<Whatever>.
Java chose erasure in Java 5 so that generic code and old non-generic code could run together, using the same class files. The cost is that the type argument isn’t there at run time, so any operation that needs it at run time can’t be written.
Where the analogy breaks: the label isn’t always gone. The declared types of fields, method parameters and superclasses keep their generic signatures in the class file, and reflection can read them. What’s lost is the type argument of each object: a particular ArrayList doesn’t know it was created as ArrayList<String>. And the grown-up can be tricked: a raw type or an unchecked cast skips the check, and then a sock really can end up in the lunchbox.
Things erasure rules out
You can’t create a T, because at run time there’s no T to call a constructor on:
class Factory<T> {
T make() {
return new T();
}
}
void main() {
IO.println(new Factory<String>());
}
The build fails with:
Main.java:3: error: unexpected type
return new T();
^
You can’t create a T[] either. An array stores its element type at run time, and erasure means there isn’t one to store:
<T> T[] makeArray(int size) {
return new T[size];
}
void main() {
IO.println(makeArray(3).length);
}
The build fails with:
Main.java:2: error: generic array creation
return new T[size];
^
The usual way round both is to pass in something that can do the creating, such as a Class<T> or a Supplier<T>.
You also can’t ask an object whether it’s a List<String>, because the object doesn’t know:
void main() {
Object thing = new ArrayList<String>();
IO.println(thing instanceof List<String>);
}
The build fails with:
Main.java:3: error: Object cannot be safely cast to List<String>
IO.println(thing instanceof List<String>);
^
This one surprised us. The error isn’t about generics being banned in instanceof. It’s about safety. If the variable is a Collection<String>, then thing instanceof List<String> compiles and runs, because anything that passes the check really is a list of strings. Java 16 added that: javac --release 15 rejects it. From Object, ask instanceof List<?> instead.
Finally, two overloads that differ only in their type arguments can’t live in the same class:
void print(List<String> items) {
IO.println("strings");
}
void print(List<Integer> items) {
IO.println("integers");
}
void main() {
print(List.of("a"));
}
The build fails with:
Main.java:5: error: name clash: print(List<Integer>) and print(List<String>) have the same erasure
void print(List<Integer> items) {
^
After erasure both methods are print(List), and a class file can’t hold two methods with the same name and parameters. Give them different names.
When a raw type or an unchecked cast lets a List<String> variable point at a list that holds an Integer, that’s called heap pollution, and the ClassCastException turns up later, far from the line that caused it, as in the first program.
Wildcards: List<Integer> is not a List<Number>
An Integer is a Number, so it seems a List<Integer> should be a List<Number>. The compiler says no:
void main() {
List<Integer> ints = new ArrayList<>(List.of(1, 2));
List<Number> nums = ints;
nums.add(2.5);
IO.println(ints);
}
The build fails with:
Main.java:3: error: incompatible types: List<Integer> cannot be converted to List<Number>
List<Number> nums = ints;
^
Line 4 is the reason. If line 3 were allowed, nums.add(2.5) would put a Double into a list that ints still promises holds only integers. Generic types are invariant: List<A> and List<B> are unrelated unless A and B are the same type.
Arrays made the other choice, and they pay for it at run time:
void main() {
Integer[] ints = {1, 2};
Number[] nums = ints;
IO.println(nums[0]);
nums[1] = 2.5;
}
It prints, then stops:
1
Exception in thread "main" java.lang.ArrayStoreException: java.lang.Double
Arrays are covariant: an Integer[] is a Number[], so the assignment compiles. The same bad write then fails when it runs. That works only because an array remembers its element type, which is exactly what erased generics can’t do. So generics catch the mistake at compile time instead.
? extends T: a list you read from
Invariance would make a simple method useless. A sum(List<Number>) couldn’t take a List<Integer>. A wildcard fixes that. List<? extends Number> means “a list of some type that’s a Number or a subtype”:
double sum(List<? extends Number> items) {
double total = 0;
for (Number n : items) {
total += n.doubleValue();
}
return total;
}
void main() {
IO.println(sum(List.of(1, 2, 3)));
IO.println(sum(List.of(1.5, 2.5)));
}
It prints:
6.0
4.0
Reading is safe. Whatever the list really holds, every element is some kind of Number. Writing isn’t:
void main() {
List<Integer> ints = new ArrayList<>();
List<? extends Number> nums = ints;
nums.add(2.5);
}
The build fails with:
Main.java:4: error: incompatible types: double cannot be converted to CAP#1
nums.add(2.5);
^
CAP#1 is javac’s name for “the unknown type behind this ?“. It might be Integer, it might be Double, and the compiler can’t prove 2.5 fits, so it refuses. You can’t add anything except null to a List<? extends Number>.
? super T: a list you write into
List<? super Integer> means “a list of Integer or one of its supertypes”. That could be a List<Integer>, a List<Number> or a List<Object>. Every one of those can hold an Integer, so adding one is safe. Reading gives you only Object, because you don’t know which of those lists you have.
The two wildcards meet in a copy method, which reads from one list and writes into another:
static <T> void copy(List<? super T> dst, List<? extends T> src) {
for (T item : src) {
dst.add(item);
}
}
void main() {
List<Integer> ints = List.of(1, 2);
List<Double> doubles = List.of(2.5);
List<Number> numbers = new ArrayList<>();
List<Object> anything = new ArrayList<>(List.of("start"));
copy(numbers, ints);
copy(numbers, doubles);
copy(anything, ints);
IO.println(numbers);
IO.println(anything);
}
It prints:
[1, 2, 2.5]
[start, 1, 2]
One method copies integers into a List<Number> and a List<Object>, and doubles into the same List<Number>. With copy(List<T> dst, List<T> src), only the first call would compile, and only if T were Integer for both lists, which numbers isn’t. The JDK’s own Collections.copy has this signature.
The rule of thumb is PECS: producer extends, consumer super. If a parameter produces values for your method to read, use extends. If it consumes values your method writes, use super. If it does both, use a plain T.
The source list produces T values, so its parameter uses extends and the method only reads from it. The destination list consumes T values, so its parameter uses super and the method only writes into it.
? on its own: any list at all
An unbounded wildcard, List<?>, means “a list of some type I don’t care about”. Use it when the method only needs things that work for every list, such as size() or printing:
String describe(List<?> items) {
return items.size() + " items, first is " + items.getFirst();
}
void main() {
IO.println(describe(List.of("a", "b")));
IO.println(describe(List.of(3.5, 7.0, 1.0)));
}
It prints:
2 items, first is a
3 items, first is 3.5
List<?> isn’t the same as the raw List. The raw type turns checking off, so you can add anything. List<?> keeps checking on, so you can’t add anything but null, and javac doesn’t warn about it.
Generic interfaces: Comparable<T> and a repository
Interfaces take type parameters too, and the JDK’s most used one is Comparable<T>. Implementing Comparable<Version> means a Version can compare itself with another Version, and then our bounded max accepts it:
record Version(int major, int minor) implements Comparable<Version> {
@Override
public int compareTo(Version other) {
if (major != other.major) {
return Integer.compare(major, other.major);
}
return Integer.compare(minor, other.minor);
}
}
static <T extends Comparable<T>> T max(List<T> items) {
T best = items.getFirst();
for (T item : items) {
if (item.compareTo(best) > 0) {
best = item;
}
}
return best;
}
void main() {
var versions = List.of(new Version(21, 0), new Version(25, 1), new Version(25, 0));
IO.println(max(versions));
}
It prints:
Version[major=25, minor=1]
compareTo takes a Version, not an Object, so there’s no cast and no instanceof inside it.
Your own interfaces can be generic in the same way. A common one in business code is a repository, which stores and finds entities by ID. Repository<T, ID> works for any entity type and any ID type:
interface Repository<T, ID> {
void save(ID id, T item);
Optional<T> findById(ID id);
List<T> findAll();
}
record User(String name) {}
class InMemoryRepository<T, ID> implements Repository<T, ID> {
private final Map<ID, T> items = new LinkedHashMap<>();
@Override
public void save(ID id, T item) {
items.put(id, item);
}
@Override
public Optional<T> findById(ID id) {
return Optional.ofNullable(items.get(id));
}
@Override
public List<T> findAll() {
return List.copyOf(items.values());
}
}
void main() {
Repository<User, Integer> users = new InMemoryRepository<>();
users.save(1, new User("Ada"));
users.save(2, new User("Linus"));
IO.println(users.findById(2));
IO.println(users.findById(7));
IO.println(users.findAll());
}
It prints:
Optional[User[name=Linus]]
Optional.empty
[User[name=Ada], User[name=Linus]]
users.save("one", ...) wouldn’t compile, because ID is Integer for this repository. Frameworks like Spring Data build their repositories on the same idea. The part on Optional covers what findById returns.
Primitives and generics: no List<int>
A type argument has to be a reference type, so primitives like int aren’t allowed:
void main() {
List<int> scores = new ArrayList<>();
IO.println(scores);
}
The build fails with:
Main.java:2: error: unexpected type
List<int> scores = new ArrayList<>();
^
Erasure is the reason. A type parameter becomes Object or a bound, and an int isn’t an object. You write List<Integer>, and Java boxes each int into an Integer on the way in and unboxes it on the way out. That’s convenient, and it has one trap that catches everyone once:
void main() {
List<Integer> scores = new ArrayList<>(List.of(1, 2, 3));
scores.remove(1);
IO.println(scores);
scores.remove(Integer.valueOf(1));
IO.println(scores);
}
It prints:
[1, 3]
[3]
List has two remove methods, remove(int index) and remove(Object o). scores.remove(1) picks the int one, because it needs no boxing, so it removes the element at index 1, which is 2. If you meant the value 1, pass an Integer, as the second call does.
Boxing also costs memory: a List<Integer> holds a reference to a separate object for every number. Project Valhalla is working on value classes that would narrow that gap, but they aren’t part of Java 25.
Reading a generic signature from the JDK
Generic signatures in the JDK look dense, but each piece answers one question, and you’ve now seen every piece. Here’s the declaration of Collections.max:
public static <T extends Object & Comparable<? super T>> T max(Collection<? extends T> coll)
Read it one piece at a time:
<T ...>:maxis a generic method with one type parameter,T.T extends ... Comparable<? super T>: aTmust be comparable withTor some supertype ofT. This is PECS again. The comparison consumes aT, so it’ssuper.Collection<? extends T> coll: the argument producesTvalues, so it’sextends. Any collection whose elements are aTor a subtype works, not just aList.Tbeforemax: the method returns aT.Object &: the first bound decides the erasure. With it, the erased method returnsObject, exactly likemaxdid before Java 5, so old compiled code that called it still links.
Step 2 is the gap in our own max. LocalDate doesn’t implement Comparable<LocalDate>. It implements Comparable<ChronoLocalDate>, an interface above it. Our <T extends Comparable<T>> can’t accept that:
static <T extends Comparable<T>> T max(List<T> items) {
T best = items.getFirst();
for (T item : items) {
if (item.compareTo(best) > 0) {
best = item;
}
}
return best;
}
void main() {
var dates = List.of(LocalDate.of(2026, 3, 1), LocalDate.of(2026, 9, 14));
IO.println(max(dates));
}
The build fails with:
Main.java:13: error: method max in class Main cannot be applied to given types;
reason: inference variable T has incompatible equality constraints ChronoLocalDate,LocalDate
Collections.max accepts the same list, and reflection confirms step 5:
void main() throws NoSuchMethodException {
var dates = List.of(LocalDate.of(2026, 3, 1), LocalDate.of(2026, 9, 14));
IO.println(Collections.max(dates));
var erased = Collections.class.getMethod("max", Collection.class);
IO.println(erased.getReturnType());
}
It prints:
2026-09-14
class java.lang.Object
Comparable<? super T> lets T be LocalDate while the comparison is defined on ChronoLocalDate. If you write a library method that compares things, copy that shape. getMethod("max", Collection.class) finds the method by its erased parameter type, because that’s the only type left at run time.
What to remember
- Generics move type errors from run time to compile time. A raw
Listbrings the lateClassCastExceptionback, and-Xlint:allwarns about it. - Type parameters go after a class name (
Box<T>) or before a method’s return type (<T> T first(...)). The compiler usually infers them. - A bound,
<T extends Comparable<T>>, lets you call the bound’s methods on aT. Several bounds join with&. - Type arguments are erased after checking. That’s why
new T(),new T[],instanceof List<String>fromObject, and overloads that differ only in type arguments don’t compile. List<Integer>isn’t aList<Number>. Arrays are covariant and fail at run time withArrayStoreExceptioninstead.- PECS: use
? extends Tfor a parameter you read from,? super Tfor one you write into, and?when the type doesn’t matter. - Type arguments must be reference types, so
intis boxed toInteger. Watchremove(int)versusremove(Object).
The compiler checks the type argument, then throws it away, so every generics rule is about what it can prove before that happens.