Why this matters
- Without generics every collection returns
Objectand every read needs a cast, so a wrong cast becomes a run-time crash instead of a compile error. - Wildcards look like syntax noise until you need to write a method that accepts "a list of any kind of number", and then they are the only answer.
- Erasure explains a handful of restrictions that otherwise seem arbitrary, and it is a reliable interview question.
The problem generics solve
// Pre-generics: the compiler cannot help you
List names = new ArrayList();
names.add("Ana");
names.add(42); // accepted — it is all Object
String first = (String) names.get(0); // cast required
String second = (String) names.get(1); // ClassCastException at run time
// With generics: the mistake is a compile error
List<String> safe = new ArrayList<>();
safe.add("Ana");
// safe.add(42); // compile error, caught immediately
String value = safe.get(0); // no cast needed
The second version fails in your editor rather than in production. That is the entire value.
Generic classes and methods
public class Box<T> {
private T content;
public void put(T content) { this.content = content; }
public T get() { return content; }
}
Box<String> text = new Box<>(); // the diamond infers String from the declaration
text.put("hello");
String value = text.get(); // typed, no cast
A generic method declares its own type parameter before the return type, which lets it be used independently of the class.
public static <T> List<T> repeat(T element, int times) {
List<T> result = new ArrayList<>();
for (int i = 0; i < times; i++) result.add(element);
return result;
}
// Two parameters, and a return type derived from both
public static <K, V> Map<V, K> invert(Map<K, V> source) {
Map<V, K> result = new HashMap<>();
source.forEach((key, value) -> result.put(value, key));
return result;
}
Conventional parameter names
T— type. The default for a single parameter.E— element. Used throughout the collections.K,V— key and value, for maps.R— result, for a function's return type.N— number.
Bounded type parameters
extends on a type parameter means "this type or a subtype", and it unlocks the bound's methods
inside the class.
// Without a bound, T could be anything, and doubleValue() would not compile
public static <T extends Number> double sum(List<T> numbers) {
double total = 0;
for (T number : numbers) {
total += number.doubleValue(); // available because of the bound
}
return total;
}
Multiple bounds are separated by &, with the class bound first if there is one:
public static <T extends Comparable<T> & Serializable> T largest(List<T> items) {
return items.stream().max(Comparator.naturalOrder()).orElseThrow();
}
Wildcards
A wildcard appears where you use a generic type rather than where you declare one. It is how you accept a family of parameterisations.
You know everything coming out is at least a Number.
You know the list can hold an Integer safely.
The guiding rule is PECS — Producer Extends, Consumer Super.
| Aspect | Wildcard | What you can do |
|---|---|---|
| List<? extends Number> | Read as Number — a producer | Cannot add anything except null |
| List<? super Integer> | Add Integer and its subtypes — a consumer | Reads come back as Object |
| List<?> | Read as Object, check size, clear | Cannot add anything except null |
| List<Object> | Read and write Object | Accepts only an actual List<Object> |
List<? extends Number>
WildcardRead as Number — a producerWhat you can doCannot add anything except nullList<? super Integer>
WildcardAdd Integer and its subtypes — a consumerWhat you can doReads come back as ObjectList<?>
WildcardRead as Object, check size, clearWhat you can doCannot add anything except nullList<Object>
WildcardRead and write ObjectWhat you can doAccepts only an actual List<Object>
If you read from the parameter use extends; if you write into it use super.
// Producer: we only read, so extends widens what callers may pass
public static double total(List<? extends Number> numbers) {
double sum = 0;
for (Number n : numbers) sum += n.doubleValue();
return sum;
}
total(List.of(1, 2, 3)); // List<Integer> — accepted
total(List.of(1.5, 2.5)); // List<Double> — accepted
// Consumer: we only write, so super widens what callers may pass
public static void fillWithDigits(List<? super Integer> target) {
for (int i = 0; i < 10; i++) target.add(i);
}
fillWithDigits(new ArrayList<Integer>()); // accepted
fillWithDigits(new ArrayList<Number>()); // accepted
fillWithDigits(new ArrayList<Object>()); // accepted
Generics are not covariant
This is the most important difference from arrays.
// Arrays are covariant — and that is a hole in the type system
Object[] objects = new String[3];
objects[0] = 42; // compiles, throws ArrayStoreException at run time
// Generics are invariant — the hole is closed at compile time
// List<Object> list = new ArrayList<String>(); // compile error
List<String> is not a subtype of List<Object>, even though String is a subtype of Object.
If it were, you could add an Integer to a List<Object> reference that actually pointed at a
List<String>. Invariance is what makes the compile-time guarantee real, and wildcards are the
controlled way to relax it when you need to.
Type erasure
The compiler checks your types and then removes them. At run time there is one ArrayList class,
not one per parameterisation.
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
System.out.println(strings.getClass() == integers.getClass()); // true — same class
System.out.println(strings.getClass().getSimpleName()); // ArrayList
Unbounded parameters erase to Object; bounded ones erase to their bound. The compiler inserts casts
at the call sites where they are needed, so the code remains type-safe from the outside.
What erasure forbids
new T()— no type information exists at run time, so there is nothing to instantiate. Pass aSupplier<T>or aClass<T>instead.new T[10]— same reason. Create anObject[]and cast, or useArray.newInstancewith aClass<T>.value instanceof List<String>— the parameter is gone; onlyinstanceof List<?>is allowed.- Primitive type arguments —
List<int>is invalid, because erasure needs a reference type. UseList<Integer>. - Overloads differing only by type argument —
process(List<String>)andprocess(List<Integer>)erase to the same signature and will not compile. - A generic type in a
catch— exception matching happens at run time, which erasure makes impossible. staticfields of a parameterised type — one field is shared by all parameterisations, soThas no meaning there.
// The workaround for new T(): pass a factory
public static <T> List<T> createMany(Supplier<T> factory, int count) {
List<T> result = new ArrayList<>();
for (int i = 0; i < count; i++) result.add(factory.get());
return result;
}
List<ArrayList<String>> lists = createMany(ArrayList::new, 3);
Raw types
Using a generic class without a type argument reverts to pre-generics behaviour and disables checking for that whole reference.
List raw = new ArrayList<String>();
raw.add(42); // unchecked warning, compiles
List<String> strings = raw; // unchecked warning
String broken = strings.get(0); // ClassCastException here, far from the cause
Never use raw types in new code. When you must call a legacy API that returns one, convert it as soon as possible and confine the warning:
@SuppressWarnings("unchecked") // narrowest possible scope, with a reason
List<String> safe = (List<String>) legacyApi.fetch();
Common misreadings
- "
List<String>andList<Integer>are different classes at run time." They are the same class. Erasure leaves one. - "
List<Object>accepts any list." It accepts only an actualList<Object>. For any list, useList<?>. - "You can add to a
List<? extends Number>." Onlynull. The element type is unknown. - "Arrays and generics behave the same way." Arrays are covariant and fail at run time; generics are invariant and fail at compile time.
- "The diamond
<>is optional sugar." It is inference. Omitting the type argument entirely gives you a raw type, which is different. - "A raw type is just a shorter spelling." It disables generic checking for every use of that reference.
Quick recall
Everything you need if you only revisit this box.
- Generics move type errors from run time to compile time, and remove casts.
- Bound a parameter with
extendsto use the bound's methods; combine bounds with&. - PECS — Producer
extends, Consumersuper. Read from? extends, write into? super. - You cannot add anything but
nullto a? extendscollection. - Generics are invariant:
List<String>is not aList<Object>. Arrays are covariant and unsafe. - Erasure removes type arguments, so no
new T(), nonew T[], noinstanceof List<String>, no primitives, and no overloads differing only by type argument. - Never use raw types. They disable checking and move the failure far from its cause.
Test yourself
Answer these before moving on — recall is what makes it stick.