Generics
Before generics (pre-Java 5), a reusable container had to store Object and force callers to cast on the way out — losing compile-time type safety entirely.
🎯 What you'll learn
- The Problem Generics Solve
- Generic Classes
- Generic Methods
- Multiple Type Parameters
- Bounded Type Parameters
1The Problem Generics Solve
Before generics (pre-Java 5), a reusable container had to store Object and force callers to cast on the way out — losing compile-time type safety entirely.
// pre-generics style
java.util.List raw = new java.util.ArrayList();
raw.add("hello");
raw.add(42); // compiles fine — no type checking at all
String s = (String) raw.get(1); // compiles, but throws ClassCastException at RUNTIME// generic style
java.util.List<String> typed = new java.util.ArrayList<>();
typed.add("hello");
typed.add(42); // compile error — caught immediately, not at runtime2Generic Classes
class Box<T> {
private T value;
Box(T value) { this.value = value; }
T get() { return value; }
void set(T value) { this.value = value; }
}
Box<String> stringBox = new Box<>("hello");
Box<Integer> intBox = new Box<>(42);3Generic Methods
A type parameter can belong to a single method instead of the whole class, declared before the return type.
static <T> T firstOf(java.util.List<T> list) {
return list.get(0);
}
String first = java.util.List.of("a", "b");
String result = firstOf(first); // T inferred as String4Multiple Type Parameters
class Pair<A, B> {
A first; B second;
Pair(A first, B second) { this.first = first; this.second = second; }
}
Pair<String, Integer> entry = new Pair<>("age", 30);5Bounded Type Parameters
extends on a type parameter restricts what T can be, and — critically — grants access to methods declared on the bound, which an unbounded T (implicitly bounded only by Object) doesn't have.
static <T extends Comparable<T>> T max(java.util.List<T> list) {
T best = list.get(0);
for (T item : list) {
if (item.compareTo(best) > 0) best = item; // compareTo only exists because of the bound
}
return best;
}
max(java.util.List.of(3, 1, 4, 1, 5)); // 5Multiple bounds are allowed with & (at most one class, plus any number of interfaces): <T extends Number & Comparable<T>>.
6Wildcards: ? extends and ? super (PECS)
Wildcards describe an unknown but constrained type argument for a variable's usage site, distinct from a type parameter declared at a class/method's definition site.
static double sumOf(java.util.List<? extends Number> list) { // can READ as Number; cannot add()
double total = 0;
for (Number n : list) total += n.doubleValue();
return total;
}
sumOf(java.util.List.of(1, 2, 3)); // List<Integer> — accepted
sumOf(java.util.List.of(1.5, 2.5)); // List<Double> — also accepted
static void addNumbers(java.util.List<? super Integer> list) { // can WRITE Integer; reads only as Object
list.add(1);
list.add(2);
}PECS — "Producer Extends, Consumer Super": use ? extends T for a structure you only read from (it produces T values for you), and ? super T for a structure you only write into (it consumes T values from you). This is why sumOf above can safely iterate the list as Number but could not call .add(...) on it — the compiler cannot guarantee which subtype of Number the underlying list actually holds, so allowing a write could insert the wrong subtype.
7Raw Types
Using a generic class without a type argument at all — List instead of List<String> — is legal for backward compatibility with pre-Java-5 code, but disables generic type checking for that usage entirely and produces "unchecked" compiler warnings.
java.util.List raw = new java.util.ArrayList<String>(); // raw type — legal, but a red flag
raw.add(42); // compiles with a warning; corrupts what was meant to be a List<String>Never use raw types in new code — they exist purely for migrating old pre-generics codebases.
Under the HoodHow it works internally▾
Type erasure: generics are a compile-time-only construct. After the compiler finishes checking a generic class or method, it erases all type parameter information: T (unbounded) becomes Object, and T extends Number becomes Number, in the actual compiled bytecode. There is exactly one compiled .class file for Box<T>, used identically for Box<String> and Box<Integer> — unlike C++ templates, which generate separate compiled code per instantiation (monomorphization). At every point where erased code would otherwise lose type safety — e.g., reading a value back out of Box<String> — the compiler inserts an invisible checkcast bytecode instruction at the call site, so String s = stringBox.get(); really compiles to "call get(), which returns Object, then checkcast it to String." This is the exact mechanism that makes putting the wrong type into a raw-typed generic collection blow up later, far from where the bad add() call actually happened — the corrupted element sits harmlessly until some other piece of code tries to read it back out and the inserted checkcast fails.
Bridge methods: erasure creates real signature mismatches the compiler must paper over. Consider a class implementing Comparable<T>:
class MyBox implements Comparable<MyBox> {
public int compareTo(MyBox other) { return 0; }
}After erasure, the Comparable interface's method signature is compareTo(Object) (since its own T is erased to Object), but MyBox declares compareTo(MyBox) — these are two genuinely different signatures at the bytecode level, and MyBox would fail to actually implement the erased interface method. The compiler solves this by generating a synthetic bridge method, compareTo(Object o) { return compareTo((MyBox) o); }, invisible in source but present in the compiled class — it satisfies the interface's erased signature and delegates to the real, type-specific method via a cast. Bridge methods are a genuinely Java-specific consequence of combining erasure with polymorphic dispatch; they show up (as extra, synthetic entries) if you reflectively enumerate a generic class's methods, which surprises people who assume getDeclaredMethods() only returns what they wrote.
Restrictions that exist specifically because of erasure. You cannot write new T(), new T[], or x instanceof T for an unbounded type parameter T — at runtime, after erasure, there is no T left to instantiate, allocate an array of, or check against; the JVM would need to consult information that no longer exists in the compiled class. instanceof against a parameterized type like list instanceof List<String> is similarly illegal (only the raw list instanceof List is allowed) — for the same reason. This is a meaningfully different and more restrictive story than TypeScript's erasure: TypeScript erases to JavaScript, a dynamically-typed language with no static array-allocation-by-type step to begin with, so the "can't new T[]" restriction has no real TypeScript analog — it's a specifically Java (JVM-array-typing) consequence, not something erasure alone implies in every language.
⚠️ Common Pitfalls
- Trying to create a generic array (
new T[10]) — illegal due to erasure (see Under the Hood); the usual workaround is(T[]) new Object[10]with an unchecked-warning suppression, or preferring aList<T>over an array entirely. - Mixing raw types with generic types —
List raw = someGenericList;compiles with only a warning, and any subsequentadd()through the raw reference can silently corrupt the underlying, supposedly-typed collection. - Using
? extends Twhere you need to write, or? super Twhere you need to read back a specific type — violates PECS; the compiler will rejectadd()on a? extendswildcard and reject anything butObjectreads from a? superwildcard, by design, not by accident. - Assuming
instanceof/reflection can recover the concrete type argument at runtime —list instanceof List<String>doesn't compile; erasure means the runtime object genuinely has no record ofStringas its element type once compiled. - Forgetting a static context can't reference the enclosing class's type parameter —
class Box<T> { static T value; }doesn't compile:staticmembers belong to the class itself, not to any particular instantiation likeBox<String>, and erasure means there's no singleTa static member could consistently refer to.
📋 Quick Reference
class Box<T> { T value; } // generic class
static <T> T identity(T x) { return x; } // generic method
<T extends Comparable<T>> T max(List<T> list) { } // bounded type parameter
List<? extends Number> readable; // producer — read as Number, cannot add()
List<? super Integer> writable; // consumer — can add(Integer), read only as Object
// PECS: Producer Extends, Consumer Super
// erasure: Box<String> and Box<Integer> compile to ONE class using Object,
// with compiler-inserted checkcast at each typed read site.
// No new T(), no new T[], no `x instanceof ParameterizedType` — nothing left at runtime.
List rawList = new ArrayList<String>(); // raw type — legal but disables checking; avoid in new code🎤 Interview Q&A
Q1Why use generics?core▾
Compile-time type safety and no casts for collections and reusable code: List<String> prevents adding integers and returns Strings directly.
- Raw types? Legacy; avoid them.
Q2What is type erasure?core▾
Generic type information is removed at compile time; at runtime List<String> and List<Integer> are both List. You can't do new T(), instanceof List<String>, or create generic arrays directly.
- Workaround to keep type info? Pass
Class<T>tokens or use super type tokens.
Q3Explain wildcards and PECS.core▾
? extends T (producer, read items as T), ? super T (consumer, write T items). PECS: Producer Extends, Consumer Super, as in Collections.copy(List<? super T> dst, List<? extends T> src).
- Unbounded wildcard?
List<?>when the type doesn't matter.
Q4What are bounded type parameters?senior▾
<T extends Comparable<T>> T max(List<T> list) restricts T to comparable types, allowing calls to compareTo.
- Multiple bounds?
<T extends Number & Comparable<T>>.
Q5Why are arrays covariant but generics invariant?core▾
Arrays check element types at runtime (ArrayStoreException); generics are erased, so the compiler enforces invariance to stay safe: List<Dog> is not a List<Animal>.
- Implication? Prefer lists to arrays in APIs.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.