Java Generics — Complete Notes
Type parameters, bounds, wildcards, PECS and type erasure — everything you need to read and write modern Java confidently, explained from the problem they solve up to the tricks the compiler plays behind your back.
00. The problem generics solve
Generics let you write a class or method that works with any type, while still letting the compiler check types for you. In one line: they move type errors from runtime crashes to compile-time red squiggles.
Before generics (Java 4 and earlier), collections held plain Object. You could put
anything in, but taking things out meant casting — and nothing stopped you casting wrongly:
List list = new ArrayList(); // holds Object
list.add("hello");
list.add(42); // compiles fine — no one's checking!
String s = (String) list.get(0); // ok
String bad = (String) list.get(1); // compiles, then CRASHES: ClassCastException at runtime
List<String> list = new ArrayList<>();
list.add("hello");
list.add(42); // COMPILE ERROR — 42 is not a String
String s = list.get(0); // no cast needed, guaranteed to be a String
Type safety — the compiler rejects the wrong type before your program ever
runs. No casts — you read values out already typed, so the code is cleaner and
self-documenting. A List<String> tells the reader (and the compiler) exactly
what's inside.
A non-generic collection is a cardboard box labelled "STUFF." You can put
anything in, but you never know what you'll pull out, so you check every time. A
List<String> is a box labelled "BOOKS ONLY." The label lets
everyone put in and take out books with total confidence — and refuses a football at the door.
01. Generic classes & interfaces
You make a type generic by giving it a type parameter — a placeholder, written in angle brackets, that stands in for a real type chosen later.
class Box<T> { // T is a type parameter — a placeholder
private T value;
public void set(T value) { this.value = value; }
public T get() { return value; }
}
Box<String> sb = new Box<>(); // T becomes String here
sb.set("hi");
String x = sb.get(); // no cast
Box<Integer> ib = new Box<>(); // T becomes Integer here
ib.set(42);
// ib.set("nope"); // COMPILE ERROR
You can have several type parameters, and there's a naming convention worth knowing so you recognise it in library code:
| Letter | Conventionally means |
|---|---|
T |
Type (the general one) |
E |
Element (used all over the collections) |
K, V |
Key, Value (maps) |
N |
Number |
R |
Return type |
class Pair<K, V> {
private final K key;
private final V value;
public Pair(K key, V value) { this.key = key; this.value = value; }
public K getKey() { return key; }
public V getValue() { return value; }
}
Pair<String, Integer> age = new Pair<>("Asha", 30);
String name = age.getKey(); // String
int a = age.getValue(); // Integer, auto-unboxed to int
Interfaces take type parameters the same way — you've already used them:
Comparable<T>, Iterator<E>, List<E>.
When a class implements one, it fills in the type:
class Person implements Comparable<Person>.
02. Generic methods
A single method can be generic even if its class isn't. You declare the type parameter before the return type, and the compiler usually infers it from the arguments so you rarely spell it out.
class Util {
// <T> declares the parameter; T is then used in params and return type
public static <T> T firstOrNull(List<T> items) {
return items.isEmpty() ? null : items.get(0);
}
public static <T> void swap(T[] arr, int i, int j) {
T tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
}
}
String s = Util.firstOrNull(List.of("a", "b")); // T inferred as String
Integer n = Util.firstOrNull(List.of(1, 2, 3)); // T inferred as Integer
<T> lives tells you which kind it is
On the class header (class Box<T>) → every instance is bound to one
T. Just before a method's return type (static <T> T first(...)) → the method picks a fresh T on each call, independent of the class.
03. Bounded type parameters
Sometimes "any type" is too loose — you need "any type that can do X." A
bound constrains T to a type or its subtypes, which also
unlocks that type's methods inside your generic code.
// Without a bound, we couldn't call doubleValue() — Object has no such method.
static <T extends Number> double sum(List<T> nums) {
double total = 0;
for (T n : nums) total += n.doubleValue(); // allowed BECAUSE of the bound
return total;
}
sum(List.of(1, 2, 3)); // ok — Integer extends Number
sum(List.of(1.5, 2.5)); // ok — Double extends Number
// sum(List.of("a", "b")); // COMPILE ERROR — String is not a Number
extends here means "is a subtype of"
In a bound, extends covers both class inheritance and interface
implementation — so <T extends Comparable<T>> is valid even though
Comparable is an interface. You can even require several at once with
&: <T extends Number & Comparable<T>> means "a
number that is also comparable."
04. Wildcards — the ?
A wildcard ? means "some specific type I'm not naming." It exists because of a
surprising fact: List<String> is not a subtype of
List<Object>, even though String is a subtype of
Object. Generics are invariant.
If List<String> were a List<Object>, you could do
this: assign it to a List<Object> variable, then add(42) —
sneaking an Integer into a list of Strings. To prevent exactly that, the language forbids the
assignment. Wildcards are how you regain flexibility safely.
? extends T — an upper bound (a "producer" you read from)
// Reads numbers out; works for List<Integer>, List<Double>, List<Number>...
static double total(List<? extends Number> nums) {
double t = 0;
for (Number n : nums) t += n.doubleValue(); // reading as Number is safe
return t;
}
total(List.of(1, 2, 3)); // List<Integer> ✓
total(List.of(1.5, 2.5)); // List<Double> ✓
// nums.add(1); // NOT allowed — see below
With ? extends Number you can read elements as Number,
but you cannot add anything (except null). Why: the list might
really be a List<Integer>, and the compiler can't let you put a
Double into it. So extends = read-only-ish, a
producer of values.
? super T — a lower bound (a "consumer" you write into)
// Writes Integers in; works for List<Integer>, List<Number>, List<Object>
static void addNumbers(List<? super Integer> dst) {
for (int i = 1; i <= 3; i++) dst.add(i); // adding Integer is safe
}
List<Number> nums = new ArrayList<>();
addNumbers(nums); // ✓ — a List<Number> can certainly hold Integers
List<Object> objs = new ArrayList<>();
addNumbers(objs); // ✓
With ? super Integer you can add Integers safely (any
supertype list can hold them), but when you read, the best you get back is
Object — because the list could be a List<Object>. So
super = write-friendly, a consumer of values.
Unbounded ?
A bare List<?> means "a list of some unknown type." You can read elements as
Object and check size(), but you can't add anything (except
null). It's handy for methods that don't care about the element type, like
printAll(List<?> list).
05. PECS — the rule that makes wildcards click
Producer Extends, Consumer Super. This one mnemonic tells you which wildcard to use every time.
-
If the parameter produces values for you (you read out of it), use
? extends T. -
If the parameter consumes values from you (you write into it), use
? super T. - If it does both, use an exact type
T(no wildcard).
static <T> void copy(List<? super T> dst, // dst CONSUMES T → super
List<? extends T> src) { // src PRODUCES T → extends
for (T item : src) dst.add(item);
}
List<Number> dst = new ArrayList<>();
List<Integer> src = List.of(1, 2, 3);
copy(dst, src); // ✓ read Integers out of src, write them into a Number list
"I'm reading numbers out of src, so src is a producer →
extends. I'm writing numbers into dst, so dst is
a consumer → super." If you can say which direction the data flows, PECS picks the
wildcard for you.
06. Type erasure — how generics actually work
Here's the twist that explains most of generics' quirks: they exist only at compile time. Once your code compiles, the type information is erased, and the bytecode looks much like the old pre-generics code.
The compiler uses your <T> to check your code, then throws the
parameter away: unbounded T becomes Object (a bounded
<T extends Number> becomes Number), and it silently inserts the
casts you didn't have to write. This is why generics are said to be
"syntactic sugar" over casting.
// You write:
Box<String> b = new Box<>();
String s = b.get();
// After erasure, the bytecode is effectively:
Box b = new Box();
String s = (String) b.get(); // the cast the compiler added for you
// And at runtime these are the SAME class:
List<String> a = new ArrayList<>();
List<Integer> c = new ArrayList<>();
System.out.println(a.getClass() == c.getClass()); // true — both are just ArrayList
Erasure was chosen for backward compatibility: pre-generics code and generic code had to interoperate and run on the same JVM. The cost is that types aren't available at runtime — a limitation called being non-reified. Languages like C# reify generics; Java does not.
07. What erasure forbids
Almost every "you can't do that with generics" rule traces back to erasure: at runtime,
T simply doesn't exist.
| You can't… | Because… |
|---|---|
new T() |
the JVM doesn't know what T is at runtime |
new T[10] |
same — can't create an array of an erased type |
if (obj instanceof List<String>) |
the <String> is gone; only instanceof List<?> is
allowed
|
use a primitive: List<int> |
type parameters must be reference types — use List<Integer> |
a static field of type T |
T belongs to an instance, not the class |
catch (MyException<T> e) |
exceptions can't be generic — the runtime can't tell them apart |
// Can't do: T t = new T();
// Instead, take a Supplier and let the caller decide how to build one:
static <T> List<T> filled(int n, Supplier<T> factory) {
List<T> list = new ArrayList<>();
for (int i = 0; i < n; i++) list.add(factory.get());
return list;
}
List<StringBuilder> sbs = filled(3, StringBuilder::new);
08. Generics vs arrays — a real clash
Arrays and generics behave in opposite ways, and mixing them is a known sharp edge.
-
Arrays are covariant:
String[]is anObject[]. This compiles — but can blow up at runtime. -
Generics are invariant:
List<String>is not aList<Object>. This is caught at compile time.
Object[] arr = new String[3]; // legal — arrays are covariant
arr[0] = 42; // compiles… then throws ArrayStoreException at runtime
List<Object> list = new ArrayList<String>(); // COMPILE ERROR — caught immediately (good!)
new List<String>[10] is illegal, and new T[10] can't be done.
When you need a "generic array," use a List<T> instead — it's the idiomatic
Java answer and avoids the whole minefield.
09. Type inference & the diamond
The compiler is good at figuring out type parameters so you don't repeat yourself.
// The diamond <> (Java 7+): don't repeat the type on the right
Map<String, List<Integer>> m = new HashMap<>(); // not new HashMap<String, List<Integer>>()
// Method type inference — no need to write Util.<String>firstOrNull(...)
String s = Util.firstOrNull(List.of("a", "b"));
// var (Java 10+) — infers the whole variable type
var names = new ArrayList<String>(); // names is ArrayList<String>
Writing List list = new ArrayList(); with no type parameter uses a
raw type. It compiles (for backward compatibility) but disables all generic
checking and earns "unchecked" warnings. Never use raw types in new code — always specify the
type or use <>.
10. Gotchas — where Java surprises you
1. You can't overload on generic type alone.
void f(List<String>) and void f(List<Integer>) won't
compile side by side — after erasure both are f(List), an identical signature.
2. A wildcard list is almost read-only.
You cannot add to a List<? extends T> (except null)
— the compiler can't know the exact element type is compatible.
3. "Unchecked cast" warnings mean the compiler can't verify you.
Casting (List<String>) someRawList compiles with a warning because erasure
means the cast can't actually be checked at runtime. Fix the types rather than suppressing the
warning blindly.
4. Bounded T erases to its bound, not to Object.
<T extends Number> erases T to Number, so inside
the method you can call Number's methods on a T.
11. Interview Q&A
Q: What is type erasure?
The compiler checks generic types, then removes the parameters from the bytecode —
T becomes Object (or its bound) and casts are inserted automatically.
Consequence: generic type info isn't available at runtime, and
List<String> and List<Integer> are the same class. It was
chosen for backward compatibility.
Q: Explain PECS.
Producer Extends, Consumer Super. Use ? extends T for a source you read from,
? super T for a destination you write to. It maximises how many types your API
accepts while staying type-safe.
Q: Why can't you write new T() or new T[n]?
Because T is erased — at runtime there's no such type to instantiate. Work around
it by passing a Supplier<T> or a Class<T>, and use a
List<T> instead of a generic array.
Q: Difference between List<Object>, List<?> and a raw
List?
List<Object> — a list you can add any object to. List<?> —
a list of some unknown type; read as Object, can't add (except null). Raw
List — no generics at all; compiles for legacy reasons but unsafe, avoid it.
12. Cheat sheet
- Why: compile-time type safety + no casts.
-
Class:
class Box<T>· method:static <T> T f(...)(declare<T>before the return type). -
Bound:
<T extends Number>— restrictsTand unlocks that type's methods. -
Wildcards:
? extends T= read (producer);? super T= write (consumer);?= unknown. - PECS: Producer Extends, Consumer Super.
-
Invariance:
List<String>is not aList<Object>. -
Erasure: generics vanish at runtime → no
new T(), nonew T[], noT.class, no primitives, no generic exceptions. -
Arrays vs generics: arrays covariant (runtime-checked), generics invariant
(compile-checked) — prefer
List<T>over arrays. - Never use raw types in new code.