Java Generics: Type Parameters Done Right
Write Java generics that compile and read well. Type parameters, bounded wildcards, PECS, type erasure, and how to avoid raw types in your own code.
What you'll learn
- ✓Declare generic classes and methods
- ✓Use bounded type parameters and multiple bounds
- ✓Apply PECS to choose extends vs super wildcards
- ✓Understand type erasure and its consequences
- ✓Avoid raw types and unchecked warnings in your own code
Prerequisites
Generics let you write code that works for many types while keeping full type safety at compile time. The syntax is famously fiddly, but the rules behind it are simple once you see them as a contract about who reads from and who writes to a container.
In this article I will write generic type parameters inside code blocks, since outside backticks they look like HTML.
A generic class
A type parameter goes between angle brackets after the class name. By convention, single letters: T for type, E for element, K and V for key and value.
public class Box<T> {
private final T value;
public Box(T value) {
this.value = value;
}
public T get() {
return value;
}
}
Box<String> b = new Box<>("hello");
String s = b.get(); // no cast required
The diamond <> on the right lets the compiler infer the type argument.
Generic methods
A method can declare its own type parameter before the return type:
public static <T> T firstOrDefault(List<T> list, T fallback) {
return list.isEmpty() ? fallback : list.get(0);
}
String name = firstOrDefault(List.of(), "anonymous");
The compiler infers T from the arguments. You rarely write it explicitly.
Bounded type parameters
Constrain the type with extends (works for classes and interfaces).
public static <T extends Comparable<T>> T max(List<T> items) {
T best = items.get(0);
for (T item : items) {
if (item.compareTo(best) > 0) best = item;
}
return best;
}
Multiple bounds use &:
public static <T extends Number & Comparable<T>> T smaller(T a, T b) {
return a.compareTo(b) <= 0 ? a : b;
}
The first bound must be a class if any; the rest must be interfaces.
Wildcards
Wildcards (?) describe an unknown type at the use site. The two bounded forms are the ones you will read in real code:
? extends Foois an unknown type that isFooor a subtype.? super Foois an unknown type that isFooor a supertype.
// Reads Numbers; accepts a List of Integer, Double, etc.
double sum(List<? extends Number> nums) {
double total = 0;
for (Number n : nums) total += n.doubleValue();
return total;
}
// Writes Integers; accepts a List of Integer, Number, or Object.
void addInts(List<? super Integer> out) {
out.add(1);
out.add(2);
}
You cannot add to a List<? extends Number> (the compiler does not know which subtype it actually holds), and you can only read Object out of a List<? super Integer>.
PECS: producer extends, consumer super
A mnemonic from Joshua Bloch. If a parameter produces values for your code, use extends. If it consumes values from your code, use super. If it does both, use the exact type.
public static <T> void copy(List<? extends T> src, List<? super T> dst) {
for (T t : src) dst.add(t);
}
src produces Ts, dst consumes them. This signature accepts a wide range of combinations safely.
Type erasure
At runtime, generic types are erased. A Box<String> and a Box<Integer> are both just Box. Consequences:
- You cannot do
new T(). The runtime does not know whatTis. - You cannot use
instanceof Box<String>. Onlyinstanceof Boxor the pattern form. - You cannot create generic arrays directly:
new T[10]is illegal. - Two methods cannot overload only on different generic parameters; after erasure they have the same signature.
Workarounds:
// Pass a Class token to recover the type at runtime
public static <T> T parse(String s, Class<T> type) { /* ... */ }
// Use Object[] internally and cast at the boundary
@SuppressWarnings("unchecked")
T[] arr = (T[]) new Object[10];
Raw types
A raw type is a generic class used without its type argument: just List instead of List<String>. They exist only for backward compatibility with pre-Java 5 code. Using them defeats type safety and triggers unchecked warnings.
Bad:
List names = new ArrayList(); // raw, avoid
names.add(42);
String s = (String) names.get(0); // ClassCastException at runtime
Good:
List<String> names = new ArrayList<>();
If you must interoperate with old code, use List<?> instead of raw List; you get the “unknown type” semantics without losing the type system.
Generic interfaces
public interface Repository<E, ID> {
E findById(ID id);
void save(E entity);
}
public class UserRepo implements Repository<User, Long> {
public User findById(Long id) { /* ... */ return null; }
public void save(User u) { /* ... */ }
}
Bind the parameters in the implementation. The compiler tracks the substitutions.
Method references with generics
Most generic method calls are inferred. You rarely need to spell the type out:
List<Integer> nums = Stream.of("1", "2", "3")
.map(Integer::parseInt)
.toList();
If inference fails, use the explicit form: Collections.<String>emptyList().
A real world example
A small typed event bus:
public class EventBus {
private final Map<Class<?>, List<Object>> listeners = new HashMap<>();
public <T> void on(Class<T> type, java.util.function.Consumer<T> handler) {
listeners.computeIfAbsent(type, k -> new ArrayList<>()).add(handler);
}
@SuppressWarnings("unchecked")
public <T> void emit(T event) {
var handlers = listeners.getOrDefault(event.getClass(), List.of());
for (Object h : handlers) {
((java.util.function.Consumer<T>) h).accept(event);
}
}
}
The single unchecked cast is contained inside emit. Callers stay type safe.
Wrap up
Use type parameters on classes and methods you author, lean on wildcards at API boundaries, and remember PECS when you write helpers. Generics combine with Streams and Lambdas to make Java surprisingly expressive, even before you touch any frameworks.