Generics are the part of Java that is enforced entirely before your program runs. javac proves that every List<String> really does hold strings, writes the casts you would otherwise write by hand, and then erases the evidence — leaving bytecode in which every type parameter is Object. Almost every rule that looks arbitrary, from the ban on new T[] to the existence of @SafeVarargs, is a consequence of that one decision.
Generics are a compile-time contract
A type parameter is a promise the compiler agrees to police and then throws away. class Box<T> declares that every instance is a box of some one type, chosen at the point of use; javac then refuses any program in which that promise could be broken, and emits bytecode in which T is simply Object. Nothing checks the promise at run time, because by then there is nothing left to check.
That is not quite the whole story, and the exception matters. The class file does keep the declared generic types, in a side table called the Signature attribute, so that a class compiled today can be type-checked against a library compiled last year without the source. Run javap -v on a compiled Box<T> and you see both layers at once: the descriptor the JVM executes, and the signature javac reads.
// javap -v -p Valid$Box.class
Signature: <T:Ljava/lang/Object;>Ljava/lang/Object; // the class
void set(T);
descriptor: (Ljava/lang/Object;)V // what the JVM runs
Signature: (TT;)V // what javac readsThe distinction between descriptor and signature explains almost every surprising thing generics do. The descriptor is reality; the signature is the contract. Where a rule feels arbitrary, it is usually the language protecting a contract that reality cannot enforce.
Erasure — the decision the whole design follows from
Java added generics in 5.0 to a language that already had a decade of deployed collection code. The constraint was brutal: existing binaries had to keep running unchanged, existing source had to keep compiling, and a List from 2001 had to be interoperable with a List<String> from 2004 in both directions. Erasure is what buys that. There is exactly one ArrayList class, one Class object for it, one set of loaded metadata, whether your program uses one parameterization or four hundred — see JVM memory model for what per-class metadata actually costs.
The bridge between the two worlds is the raw type. List without arguments is assignable to and from any parameterization, and the compiler downgrades to an unchecked warning what it would otherwise reject. That is the migration path, not a style. Raw types also poison everything they touch: calling any method through a raw receiver erases all of that member's generic information, not just the parameter you skipped.
Where does the type safety go, if the bytecode has none? Into the call site. The compiler inserts the cast you would have written by hand, at every point where a generic value is consumed at a more specific type.
static String first(List<String> l) { return l.get(0); }
// javap -c
2: invokeinterface java/util/List.get:(I)Ljava/lang/Object;
7: checkcast class java/lang/String
10: areturnGenerics did not remove the casts. They moved authorship from you to the compiler, and made it prove that each one succeeds.
What erasure forbids, and why each rule follows
The prohibition list looks like a grab-bag until you apply one test: could this expression need the type argument at run time? If yes, it is illegal, because the type argument is not there. Each of the following is a compile error, with the diagnostic javac 23 actually prints.
class Box<T> {
static T shared; // non-static type variable T cannot be
// referenced from a static context
T[] make() { return new T[10]; } // generic array creation
Class<?> c() { return T.class; } // cannot select from a type variable
T fresh() { return new T(); } // unexpected type
boolean is(Object o) {
return o instanceof T; // Object cannot be safely cast to T
}
}
void f(List<String> a) {}
void f(List<Integer> a) {}
// name clash: f(List<Integer>) and f(List<String>) have the same erasure
class MyEx<T> extends Exception {}
// a generic class may not extend java.lang.ThrowableThe static-field rule follows because one static field is shared by every parameterization; there is no Box<String>.shared distinct from Box<Integer>.shared. The Throwable rule follows because catch is a run-time type test, and a catch clause on MyEx<String> could not distinguish itself from MyEx<Integer>. The erasure clash follows because two methods whose descriptors are identical cannot coexist in one class file — the JVM would see one method declared twice.
Bridge methods — the synthetic overrides you never wrote
Erasure creates a problem the compiler has to paper over. When StringNode extends Node<String> overrides set(String), the superclass method has erased to set(Object). Those are different descriptors, so by the JVM's rules the subclass method does not override anything, and a virtual call through a Node reference would dispatch to the wrong body. javac fixes it by emitting a bridge method: a synthetic set(Object) that casts and delegates.
class Node<T> { void set(T value) { this.value = value; } }
class StringNode extends Node<String> {
@Override void set(String value) { super.set(value.trim()); }
}
// javap -c -p StringNode.class
void set(java.lang.String); // the method you wrote
void set(java.lang.Object); // the bridge javac added
0: aload_0
1: aload_1
2: checkcast class java/lang/String
5: invokevirtual set:(Ljava/lang/String;)VBridges are why an unchecked call through a raw reference fails where you did not write a cast. Node raw = new StringNode("x"); raw.set(Integer.valueOf(1)); compiles with a warning and throws ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String from inside a method that does not exist in your source. They are also why you occasionally get a name-clash error naming a method you never declared: two inherited members whose bridges would collide.
Invariance, and why arrays behave differently
List<String> is not a List<Object>. Generic types are invariant: distinct type arguments give unrelated types, and javac says so flatly — incompatible types: List<String> cannot be converted to List<Object>. The reason is the obvious one: through a List<Object> view you could add an Integer to a list the other reference believes holds only strings.
Arrays made the opposite choice, in 1995, before generics existed. String[] is an Object[] — arrays are covariant — and the language pays for it with a run-time check on every array store.
Object[] objs = new String[1];
objs[0] = 42; // compiles; ArrayStoreException at run time
List<String>[] lists = new List<String>[3]; // error: generic array creation
@SuppressWarnings("unchecked")
List<String>[] ok = (List<String>[]) new List[4]; // the unsafe escape hatch
String[] a = Stream.of("a", "b").toArray(String[]::new); // the safe oneThose two facts together explain the ban on generic array creation. Arrays are reified — an array object knows its component type and enforces it — while generics are erased. A List<String>[] would be an array whose store check knows only List, so it would accept a List<Integer> silently and hand you a ClassCastException much later, at an unrelated read. Prefer List<List<String>>, or toArray(String[]::new), over the cast. Java expresses variance at the use site with wildcards, where Scala puts it on the declaration; that contrast is worked through in the Scala type system.
Wildcards, PECS, and capture
Invariance is correct and inconvenient. A method that only reads out of a collection has no reason to reject List<Integer> just because it declared List<Number>. Wildcards restore the flexibility without losing soundness, by trading away the operation that would break it.
static double sumOf(List<? extends Number> src) { // producer: read only
double total = 0;
for (Number n : src) total += n.doubleValue();
return total;
}
static <T> void copyAll(Collection<? super T> dest, // consumer: write only
Collection<? extends T> src) {
for (T t : src) dest.add(t);
}That is PECS — producer extends, consumer super. From a ? extends Number you can read a Number but cannot add anything, because the actual element type is some unknown subtype: l.add(1) fails with incompatible types: int cannot be converted to CAP#1. Into a ? super T you can write any T but reads come back as Object. An unbounded List<?> accepts only null — which is what makes it the safe replacement for a raw List.
CAP#1 is the compiler naming the unknown type, and you can force it into the open. When a wildcard method needs to relate two of its own positions, delegate to a private generic helper; the type variable captures the wildcard for the duration of the call.
public static void swap(List<?> list, int i, int j) {
swapHelper(list, i, j); // capture happens here
}
private static <E> void swapHelper(List<E> list, int i, int j) {
list.set(i, list.set(j, list.get(i)));
}Generic methods and where inference stops
A generic method declares its own type variables, independent of the class, and in almost all code you never write the type argument. Inference solves for it from the arguments and from the target type — the type the expression is being used at. That is why Collections.emptyList() can produce a List<String> with nothing in the call to indicate it.
List<String> a = Collections.emptyList(); // inferred from the target
List<String> b = Collections.<String>emptyList(); // explicit type witness
Map<String, List<Integer>> m = new HashMap<>(); // diamond
var loose = new ArrayList<>(); // ArrayList<Object> - no target!
List<Integer> lengths = Stream.of("a", "bb")
.map(String::length)
.collect(Collectors.toList());The failure modes cluster in the places where the target type disappears. var is the sharpest: var loose = new ArrayList<>() has no target type to infer from, so the diamond resolves to Object and every later use is wrong in a way that reads as unrelated. Nested generic calls fail similarly, because an argument position gives inference less to work with than an assignment does; the fix is a type witness, List.<String>of(), or a named local variable.
The other rough edge is lambdas. A lambda has no type of its own — it is a poly expression, typed by its target — so a lambda passed to a generic method whose type variable is still being solved can leave the compiler with nothing to anchor on. Ascribing the parameter type explicitly, (String s) -> s.length(), usually unblocks it.
Recursive bounds and the self-type idiom
<T extends Comparable<T>> looks circular and is not. It says: T is a type that can be compared with itself — a constraint you cannot express any other way, and the one that makes generic sorting possible. It appears in the JDK's own declaration of Enum<E extends Enum<E>>.
static <T extends Comparable<? super T>> T maxOf(Collection<? extends T> c) {
Iterator<? extends T> it = c.iterator();
T best = it.next();
while (it.hasNext()) {
T next = it.next();
if (next.compareTo(best) > 0) best = next;
}
return best;
}The ? super T in the bound is the PECS rule applied to the bound itself: it lets you take the max of a List<LocalDate> even if the comparison logic was inherited from a supertype. The JDK writes its own Collections.max with an extra twist, <T extends Object & Comparable<? super T>>, purely so the method erases to return Object rather than Comparable and stays binary-compatible with the pre-generics version.
The same shape solves inherited builders, where each method must return the most derived builder type rather than the one that declared it.
abstract class Builder<SELF extends Builder<SELF>> {
String url;
@SuppressWarnings("unchecked")
protected final SELF self() { return (SELF) this; }
public SELF url(String u) { this.url = u; return self(); }
}
class HttpBuilder extends Builder<HttpBuilder> {
int timeoutMs;
public HttpBuilder timeout(int ms) { this.timeoutMs = ms; return this; }
}
new HttpBuilder().url("x").timeout(5); // url() returns HttpBuilder, not BuilderMultiple bounds are written with & and the class, if any, must come first: <T extends Number & Comparable<T>> erases to Number, the leftmost bound.
Varargs, heap pollution and @SafeVarargs
Varargs create an array, arrays are reified, and generic arrays are illegal — so a generic varargs method is a contradiction the language permits anyway, because List.of(E...) is too useful to ban. The compiler creates the array it told you that you could not create, and warns at both ends: Possible heap pollution from parameterized vararg type T at the declaration, unchecked generic array creation at every call.
Heap pollution is the state where a variable of parameterized type refers to an object that is not of that type. The classic demonstration compiles without a single cast in sight:
static <T> T[] toArray(T... args) { return args; }
static <T> T[] pickTwo(T a, T b) { return toArray(a, b); }
String[] two = pickTwo("a", "b");
// ClassCastException: class [Ljava.lang.Object; cannot be cast to
// class [Ljava.lang.String;pickTwo has no idea what T is, so the array it hands to toArray is an Object[]; the compiler-inserted cast at the assignment is where it detonates. @SafeVarargs is your assertion that a method does not do this. It silences both warnings and is legal only where the method cannot be overridden — static, final, constructors, and since Java 9 private instance methods.
The assertion is only honest if the array never escapes: read elements, read length, do not store it and do not return it. javac will help you check — -Xlint:varargs flags exactly the escape, warning Varargs method could cause heap pollution from non-reifiable varargs parameter on static <T> T[] leak(T... items) { return items; } while staying silent on a version that copies elements into a new list.
Super type tokens and typesafe heterogeneous containers
Erasure kills the instance's type argument, not the declaration's — and a declaration is something you can create on demand. That is the super type token idiom that every JSON library ships: subclass a generic type, and the type argument you supplied is frozen into the subclass's Signature attribute, where reflection can read it back.
abstract class TypeRef<T> {
private final Type type;
protected TypeRef() {
Type sc = getClass().getGenericSuperclass();
this.type = ((ParameterizedType) sc).getActualTypeArguments()[0];
}
public Type type() { return type; }
}
TypeRef<List<String>> ref = new TypeRef<List<String>>() {};
ref.type(); // java.util.List<java.lang.String>The trailing {} is load-bearing: it creates an anonymous subclass, and without it there is no declaration to interrogate. This is the mechanism behind Jackson's TypeReference and Gson's TypeToken, and the reason they insist on that odd double-brace syntax.
The related idiom parameterizes the key rather than the container, giving a map whose values have different types and are still checked. Class<T> is the type token; type.cast(...) turns the unchecked cast into a real run-time check that fails at insertion instead of at some distant read.
class Favorites {
private final Map<Class<?>, Object> map = new HashMap<>();
<T> void put(Class<T> type, T instance) {
map.put(Objects.requireNonNull(type), type.cast(instance));
}
<T> T get(Class<T> type) { return type.cast(map.get(type)); }
}Generics with records, sealed interfaces and pattern matching
Records and sealed interfaces are generic like any other class, and the combination is the standard way to write a result or option type in modern Java. The type parameter threads through the permitted subtypes; the permits clause names classes, not parameterizations.
sealed interface Result<T> permits Ok, Err {}
record Ok<T>(T value) implements Result<T> {}
record Err<T>(Exception cause) implements Result<T> {}
static <T> String describe(Result<T> r) {
return switch (r) { // exhaustive, no default needed
case Ok<T>(var value) -> "ok: " + value;
case Err<T>(Exception cause) -> "err: " + cause.getMessage();
};
}
static String onStrings(Result<String> r) {
return switch (r) {
case Ok(var value) -> value.toUpperCase(); // type args inferred
case Err(var cause) -> cause.toString();
};
}Two things are worth noticing. Exhaustiveness still works: the compiler knows the permitted set and accepts the switch with no default, which is what makes adding a third case a compile error rather than a run-time surprise. And in a record pattern the type arguments can be inferred from the selector — case Ok(var value) against a Result<String> binds a String. The pattern machinery itself is covered in Java pattern matching and Java records.
Erasure still sets the limit. A pattern may not ask a question the run time cannot answer: o instanceof List<String> on an Object is rejected with Object cannot be safely cast to List<String>, while c instanceof List<String> l on a Collection<String> compiles, because there the type argument was never in doubt — only the class was. The day List<int> and reified parameterizations arrive, they arrive through Project Valhalla.
T.class, no generic Throwable, bridge methods, heap pollution — falls out of the gap between the two. Reach for wildcards by PECS to make APIs flexible, for recursive bounds to express self-relating types, and for a type token whenever you genuinely need the type argument at run time.