Generics let a class or method work with a type supplied by the caller, checked at compile time.
List<String> is the everyday face of it. Writing your own is less common, and
understanding erasure explains most of the surprising rules.
What they buy you
class Demo {
void run() {
// Before generics: everything was Object, and every read needed a cast
List raw = new ArrayList();
raw.add("hello");
raw.add(42); // nothing stops this
String first = (String) raw.get(0);
// String second = (String) raw.get(1); // ClassCastException at RUNTIME
List<String> typed = new ArrayList<>();
typed.add("hello");
// typed.add(42); // compile error — caught immediately
String value = typed.get(0); // no cast needed
System.out.println(value);
}
}
Two gains: mistakes move from runtime to compile time, and casts disappear. A raw
List still compiles today, with a warning — treat that warning as an error.
Generic classes and methods
// A class parameterised by one type
class Box<T> {
private T contents;
void put(T item) { this.contents = item; }
T get() { return contents; }
}
class Demo {
// A generic METHOD — the <T> before the return type declares the parameter
static <T> List<T> firstTwo(List<T> items) {
return items.subList(0, Math.min(2, items.size()));
}
void run() {
Box<String> box = new Box<>();
box.put("hello");
System.out.println(box.get().length()); // typed, no cast
System.out.println(firstTwo(List.of(1, 2, 3))); // [1, 2] — T inferred as Integer
}
}
The convention is single letters: T for type, E for element,
K/V for key and value, R for result.
Bounded types
class Demo {
// T must be Comparable, so compareTo is available inside
static <T extends Comparable<T>> T max(List<T> items) {
T best = items.get(0);
for (T item : items) {
if (item.compareTo(best) > 0) {
best = item;
}
}
return best;
}
void run() {
System.out.println(max(List.of(3, 1, 4))); // 4
System.out.println(max(List.of("Ana", "Bo"))); // Bo
}
}
extends here means "is or extends" and works for interfaces too. Without the bound,
T is only known to be an Object and compareTo would not
compile.
Wildcards, and PECS
This is the part that confuses people, and one rule resolves most of it. Start with the surprise:
class Demo {
void run() {
List<Integer> numbers = List.of(1, 2, 3);
// List<Number> widened = numbers; // does NOT compile
}
}
A List<Integer> is not a List<Number>, even though an
Integer is a Number. If it were, you could add a Double to it
through the wider reference and break the original list.
Wildcards restore the flexibility safely:
class Demo {
// PRODUCER: reads from the list. `? extends` accepts Integer, Double, any Number.
static double sum(List<? extends Number> numbers) {
double total = 0;
for (Number n : numbers) {
total += n.doubleValue();
}
return total; // cannot ADD to numbers — see below
}
// CONSUMER: writes into the list. `? super` accepts List<Integer>, List<Number>, List<Object>.
static void addNumbers(List<? super Integer> target) {
target.add(1);
target.add(2); // cannot READ a specific type out
}
void run() {
System.out.println(sum(List.of(1, 2.5))); // 3.5
List<Number> target = new ArrayList<>();
addNumbers(target);
System.out.println(target); // [1, 2]
}
}
PECS: Producer Extends, Consumer Super. If the parameter produces values you read,
use ? extends. If it consumes values you write, use ? super. If it does
both, use a plain type parameter.
The restrictions follow from safety. With ? extends Number the compiler knows every
element is a Number but not which subtype the list actually holds — so reading is safe
and adding is not. With ? super Integer the reverse holds.
Erasure, and what it explains
Generics exist only at compile time. The compiler checks the types, inserts casts, and then
erases the type arguments — at runtime a List<String> is just a
List. This was done so generic code could run on older JVMs, and it explains a set of
rules that otherwise look arbitrary:
class Demo {
void run(Object value) {
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
// 1. Same class at runtime
System.out.println(a.getClass() == b.getClass()); // true
// 2. Cannot test a type argument
if (value instanceof List<?> list) { // fine — wildcard only
System.out.println(list.size());
}
// if (value instanceof List<String> s) { } // does not compile
// 3. Cannot create an array of a generic type
// T[] array = new T[10]; // does not compile
}
}
A fourth consequence: you cannot overload two methods whose parameters differ only by type argument, because after erasure they have the same signature.
Keeping the type at runtime
When you genuinely need the type argument after erasure, the standard trick is to pass the class object alongside it:
class Repository<T> {
private final Class<T> type;
Repository(Class<T> type) {
this.type = type; // the one thing erasure cannot take away
}
T cast(Object value) {
return type.cast(value); // checked, and no unchecked warning
}
String describe() {
return "repository of " + type.getSimpleName();
}
}
class Demo {
void run() {
Repository<String> repo = new Repository<>(String.class);
System.out.println(repo.describe()); // repository of String
System.out.println(repo.cast("hello").length()); // 5
}
}
This is why so many framework APIs take a Class<T> parameter that looks
redundant — objectMapper.readValue(json, Person.class) is exactly this pattern. The
method cannot know T at runtime, so you hand it the evidence.
The practical rules
- Never use a raw type. If you do not care what is in it, write
List<?>, notList— the wildcard keeps the type checking. - Apply PECS to parameters, and use plain type parameters for return types. Wildcards in a return type push the problem onto every caller.
- Do not suppress warnings without a comment saying why the cast is safe.
- Prefer generic methods to generic classes when only one method needs the parameter.
Next
Multithreading is next — running code on more than one thread, and the ways that goes wrong.