Generics, Wildcards, Invariance
Generics exist to move errors from runtime to compile-time
Before generics, Java collections stored Object. Type errors appeared at runtime as ClassCastException. Generics push these errors to compile-time. But to use them correctly, you must understand invariance and wildcards.
Invariance: the most misunderstood rule
In Java, generics are invariant.
List<Integer> is NOT a subtype of List<Number>
This surprises many developers. The reason is type safety.
Why invariance exists
If List<Integer> were a subtype of List<Number>, the following would be legal:
List<Integer> ints = new ArrayList<>(); List<Number> nums = ints; // imagine this was allowed nums.add(3.14); // Double added into List<Integer>
This would break type safety. Therefore, generics are invariant.
Covariance with wildcards
If you want to accept a list of Integer, Double, or any subtype of Number, use an upper-bounded wildcard.
void printNumbers(List<? extends Number> numbers) {
for (Number n : numbers) {
System.out.println(n);
}
}
This is covariance: the list produces Numbers safely.
Important restriction
With List<? extends Number>, you cannot add elements (except null):
numbers.add(10); // compile error
The compiler does not know the exact subtype.
Contravariance with lower bounds
If your method consumes values (adds them into a structure), use lower-bounded wildcards.
void addIntegers(List<? super Integer> list) {
list.add(10);
}
This allows passing List<Integer>, List<Number>, or List<Object>.
PECS rule
Producer Extends, Consumer Super.
- If a parameter produces T → use ? extends T
- If a parameter consumes T → use ? super T
Example combining both
public static <T> void copy(
List<? extends T> source,
List<? super T> destination
) {
for (T item : source) {
destination.add(item);
}
}
This is how Collections.copy works conceptually.
Raw types: never use them
List list = new ArrayList(); // raw type
Raw types disable generics safety and reintroduce runtime failures. In production code, raw types are almost always a smell.
Generic methods vs generic classes
Use generic methods when the type parameter is only relevant to one method.
public static <T> T first(List<T> list) {
return list.get(0);
}
Use generic classes when the type parameter defines the entire object behavior.
Type erasure: runtime limitation
Java generics use type erasure. At runtime, List<String> and List<Integer> are both List.
- You cannot do instanceof List<String>.
- You cannot create new T() directly.
Production implication
Serialization frameworks, reflection-based libraries, and JSON mappers may require explicit type tokens to preserve type information.
Example: type token pattern
TypeReference<List<User>> typeRef = new TypeReference<>() {};
This captures generic type information for runtime frameworks.
API design and generics
Well-designed APIs use bounded generics to maximize flexibility while preserving type safety.
Example: flexible read-only API
public void processUsers(List<? extends User> users) {}
This allows List<AdminUser>, List<CustomerUser>, etc.
Avoid overcomplicated generic hierarchies
Complex generic bounds can reduce readability dramatically.
<T extends Comparable<T> & Serializable>
Use advanced bounds only when necessary.
Wildcard capture problem
Sometimes the compiler cannot infer type from wildcard. Introduce helper methods with explicit type parameters to resolve capture.
Common production pitfalls
- Using raw types in legacy interop.
- Overusing wildcards when a concrete type parameter is clearer.
- Forgetting PECS and blocking flexibility.
- Exposing overly strict generic signatures in public APIs.
Checklist
- Remember: generics are invariant.
- Use ? extends for producers.
- Use ? super for consumers.
- Avoid raw types.
- Keep generic signatures readable.
- Understand type erasure limitations.
Final principle
Generics are about designing safe and flexible contracts. If you misuse them, you either lose flexibility or lose safety. Mastering invariance is key to clean API design.