Generics & Type System
Before generics (Java 5), every collection was raw—(String) list.get(0) casts failed at runtime. Generics push type errors to compile time, but erasure means the JVM still sees raw types at runtime. This chapter connects syntax, wildcards, PECS, and the patterns you see in Spring, JPA, and API design.
Why generics exist
Generics let you parameterize types: a List<String> is a list that only holds strings at compile time. The compiler inserts checks; you avoid repetitive casts and ClassCastException surprises in distant code.
Problems generics solve
| Without generics | With generics |
|---|---|
Everything stored as Object | Element type known to compiler |
| Explicit cast on every read | Cast inserted by compiler (or eliminated) |
Runtime ClassCastException | Many errors at compile time |
| Documentation only in comments | Types are self-documenting API |
// Pre-generics style (still compiles with warnings today)
List raw = new ArrayList();
raw.add(42);
String s = (String) raw.get(0); // ClassCastException at runtime
// Generic style
List<String> names = new ArrayList<>();
names.add("Ada");
// names.add(42); // compile error
String name = names.get(0); // no cast
Generics are a compile-time feature. They improve type safety and enable richer APIs; they do not create new runtime types for List<String> vs List<Integer> (see erasure).
Spring JdbcTemplate.query(..., RowMapper<T>), JPA Repository<User, Long>, and HTTP clients returning Optional<Response> all rely on generics so callers stay type-safe without casting JSON payloads by hand.
Generic classes, methods & interfaces
Type parameters (commonly T, E, K, V) are placeholders bound when you declare or invoke the generic.
Generic class
public class Box<T> {
private T value;
public void set(T value) { this.value = value; }
public T get() { return value; }
}
Box<String> box = new Box<>();
box.set("payload");
String v = box.get();
Generic interface
public interface Converter<F, T> {
T convert(F from);
}
Converter<String, Integer> len = String::length;
Generic method
Type parameters on the method are independent of the class (see dedicated section). Syntax: modifiers, then <T>, then return type.
public static <T> List<T> listOf(T... elements) {
return List.of(elements);
}
Naming conventions
| Letter | Typical meaning |
|---|---|
| T | Type |
| E | Element (collections) |
| K, V | Key, Value (maps) |
| N | Number |
| R | Return type (e.g. Function<T,R>) |
Bounded type parameters
Unbounded <T> means “any reference type.”
<T extends SomeType> restricts T so you can call methods on the bound.
Single upper bound
public static <T extends Number> double sum(List<T> nums) {
double total = 0;
for (T n : nums) {
total += n.doubleValue(); // Number API available
}
return total;
}
Recursive type bound — <T extends Comparable<T>>
The type parameter must be comparable to itself—the pattern used by Collections.max and sorting APIs.
public static <T extends Comparable<T>> T max(Collection<T> coll) {
T best = null;
for (T item : coll) {
if (best == null || item.compareTo(best) > 0) {
best = item;
}
}
return best;
}
// String implements Comparable<String> — recursive bound satisfied
max(List.of("b", "a", "c")); // "c"
Multiple bounds
Syntax: <T extends A & B & C> — first bound may be a class; rest must be interfaces. T is treated as intersection type at compile time.
interface Named { String name(); }
interface Dated { Instant createdAt(); }
public static <T extends Named & Dated> void audit(T entity) {
log.info("{} at {}", entity.name(), entity.createdAt());
}
You cannot write <T extends String & Runnable> with two classes—only one class in the extends clause. Order matters: class first, then interfaces.
Wildcards & PECS
Wildcards (?) represent unknown types in APIs—especially method parameters— when you want flexibility without fixing a single type parameter for the whole class.
| Wildcard | Meaning | Can read as | Can write |
|---|---|---|---|
? | Unbounded unknown | Object | null only (except special cases) |
? extends T | Upper bounded (subtype of T) | T | Generally no (except null) |
? super T | Lower bounded (supertype of T) | Object | T and subtypes of T |
PECS — Producer Extends, Consumer Super
Producer → you GET items out → use ? extends T Consumer → you PUT items in → use ? super T List<? extends Number> producers: read Number, cannot add Integer safely List<? super Integer> consumers: add Integer, read as Object
// Producer — copy FROM src (read elements)
public static void copy(
List<? extends Number> src,
List<? super Number> dest) {
for (Number n : src) {
dest.add(n);
}
}
List<Integer> ints = List.of(1, 2, 3);
List<Number> nums = new ArrayList<>();
copy(ints, nums); // OK — ints is producer, nums is consumer
// Collections.addAll is declared:
// <T> boolean addAll(Collection<? super T> c, T... elements)
When not to use wildcards
If the same type parameter appears in multiple parameter or return positions, use a named type parameter—not wildcards—so relationships are preserved:
// Good — T links argument and return
public static <T> T identity(T value) { return value; }
// Broken with wildcards — cannot express "return same type as arg"
// ? identity(?) // not valid
Explain why List<Object> is not a supertype of List<String> (invariance)—would allow adding Object to a String list. Wildcards restore flexibility at API boundaries only.
Generic methods vs class-level generics
A generic class fixes type parameters for all instance methods (unless overridden by method-level params). A generic method declares its own type parameters—often static utilities where the class is not generic.
Class-level <T> | Method-level <T> |
|---|---|
One T for whole instance | Fresh T per method invocation |
new Box<String>() binds T | Compiler infers T from arguments |
| Instance methods use class T | Static methods often need own <T> |
public class Utils {
// Method generic — T independent of Utils
public static <T> List<T> nCopies(T value, int n) {
return Collections.nCopies(n, value);
}
// Explicit type argument when inference fails
List<String> list = Utils.<String>nCopies("x", 3);
}
public class Pair<A, B> {
private final A first;
private final B second;
// Method introduces third type parameter
public static <X, Y> Pair<X, Y> of(X first, Y second) {
return new Pair<>(first, second);
}
}
Type inference: Compiler infers type arguments from assignment target, method arguments, and chained return types (Java 7+ diamond, Java 8+ improved inference for lambdas).
Type erasure
For compatibility with pre-generics bytecode, the compiler erases type parameters to their
bounds (or Object if unbounded). Generic type information is largely gone at runtime.
What gets erased
List<String>becomesListat runtime—elements are stillObjectreferences in bytecode<T extends Number>→ boundNumberwhere needed for casts- Generic methods get bridge methods and synthetic casts inserted by compiler
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true — both ArrayList
// Cannot do: if (list instanceof List<String>) { }
// Can do: if (list instanceof List<?>) { }
Heap pollution
Mixing raw types with generics can insert non-generic elements into a generic collection—compiler warns with @SafeVarargs / unchecked on varargs:
List<String> strings = new ArrayList<>();
List raw = strings; // unchecked warning
raw.add(42); // heap pollution — Integer in List<String>
String s = strings.get(0); // ClassCastException at runtime
Unchecked cast warnings
Casting to parameterized types cannot be fully verified at runtime—the compiler emits unchecked warnings; you may suppress with @SuppressWarnings("unchecked") only when logically safe (e.g. after a type token check).
Object obj = List.of("a");
@SuppressWarnings("unchecked")
List<String> list = (List<String>) obj; // warning — verify with TypeReference in JSON libs
Bridge methods preserve polymorphism after erasure—e.g. Comparable<String>.compareTo(String) bridges to compareTo(Object). Reflection can read generic signatures via Type / ParameterizedType on fields and methods—not on bare class literals.
Reifiable vs non-reifiable types
A type is reifiable if type information is fully available at runtime—so you can use
instanceof, create arrays, and assign to Object without hidden casts.
| Reifiable | Non-reifiable (erased) |
|---|---|
| Primitives | List<String> |
| Non-generic classes | T type parameters |
| Raw types | Parameterized types with type args |
| Arrays of reifiable component | List<?> (wildcard parameterized) |
Unbounded wildcard ?[] rare | Map<K,V> |
You cannot create new T[] or new List<String>[10] (illegal generic array creation). Workarounds: (T[]) new Object[n] with warning, or ArrayList instead.
String[] ok = new String[3];
// List<String>[] bad = new List<String>[3]; // compile error
List<?> wildcardList = List.of(1, "two");
wildcardList instanceof List<?>; // OK
// wildcardList instanceof List<String>; // compile error
Capture: A wildcard type like ? extends Number in a local variable is not a valid type argument when creating nested generics—the compiler uses a capture helper type internally.
Real-world generic patterns
Production code repeats a few shapes: fluent builders tied to subclass type, persistence repositories keyed by ID type, and result types that carry success or error without throwing for control flow.
Builder<T> — recursive generic (F-bounded)
Subclass returns itself from fluent methods—classic pattern uses recursive type bound:
public abstract class Builder<T extends Builder<T>> {
protected String host = "localhost";
@SuppressWarnings("unchecked")
protected T self() { return (T) this; }
public T host(String host) {
this.host = host;
return self();
}
}
public class HttpClientBuilder extends Builder<HttpClientBuilder> {
public HttpClient build() {
return new HttpClient(host);
}
}
new HttpClientBuilder().host("api.example.com").build();
Repository<T, ID>
public interface Repository<T, ID> {
Optional<T> findById(ID id);
T save(T entity);
void deleteById(ID id);
List<T> findAll();
}
public interface UserRepository extends Repository<User, UUID> {
Optional<User> findByEmail(String email);
}
// Spring Data: interface UserRepo extends JpaRepository<User, Long>
Result<T, E> — typed success or failure
public sealed interface Result<T, E> permits Ok, Err {
static <T, E> Result<T, E> ok(T value) { return new Ok<>(value); }
static <T, E> Result<T, E> err(E error) { return new Err<>(error); }
<R> R fold(Function<? super T, ? extends R> onOk,
Function<? super E, ? extends R> onErr);
}
record Ok<T, E>(T value) implements Result<T, E> {
public <R> R fold(Function<? super T, ? extends R> onOk,
Function<? super E, ? extends R> onErr) {
return onOk.apply(value);
}
}
Similar ideas: Optional<T> for absence, Either in libraries, Vavr Try, Rust-inspired Result in modern Java services for explicit error channels.
API design: use bounded type parameters inside a class; use wildcards on public method parameters (PECS). Avoid exposing generic arrays; prefer collections. For JSON generic types, use TypeReference<T> (Jackson) to preserve parameterized types at runtime via reflection.
ResponseEntity<T> in Spring MVC, CompletableFuture<T>, Reactor Mono<T> / Flux<T>, and AWS SDK paginated Page<T> are all parameterized types—getting wildcards right at library boundaries prevents subtle compile failures in application code.