Why generics exist, and the raw-type trap
You have been reading angle brackets since module 2 — String[] args is not one,
but List<String> is, and Arrays.asList returned one. This module explains
them properly, because an intern reading real Java meets
<T extends Comparable<? super T>> in their first week and needs to not flinch.
Start with what life was like without them, because generics are a solution and the problem is more memorable than the answer.
Java before 2004
Collections held Object. Everything you took out needed a cast.
List names = new ArrayList();
names.add("Priya");
names.add("Arjun");
names.add(42);
for (Object o : names) {
String s = (String) o;
System.out.println(s.toUpperCase());
}
PRIYA
ARJUN
threw: class java.lang.Integer cannot be cast to class java.lang.String
(java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
Two names printed, then a crash. The mistake was on line four — names.add(42) —
and the failure happened somewhere else entirely, in a loop that looks correct.
In a real system the add is in one file, the loop is in another, and the crash
is in production.
That is a List without a type argument, called a raw type. It is still
legal, for backwards compatibility, and the compiler is not happy about it:
warning: [rawtypes] found raw type: List
warning: [unchecked] unchecked call to add(E) as a member of the raw type List
Never write a raw type. If you see one in existing code, adding the type argument is usually a five-second fix that turns a future runtime crash into a compile error today.
With the type argument
List<String> names = new ArrayList<>();
names.add("Priya");
names.add(42);
error: no suitable method found for add(int)
names.add(42);
^
method List.add(String) is not applicable
(argument mismatch; int cannot be converted to String)
The error is now on the line with the mistake, at compile time, before the program has ever run. And the loop needs no cast:
for (String s : names) {
System.out.println(s.toUpperCase());
}
That is the whole value proposition: generics move a class of error from runtime to compile time, and remove the casts that hid it.
new ArrayList<>() with empty brackets is the diamond operator. The compiler
infers the type argument from the left-hand side, so you write it once.
Generics are not covariant, and that is deliberate
The arrays lesson ended with this:
Object[] objects = new String[2];
objects[0] = 42;
java.lang.ArrayStoreException: java.lang.Integer
Compiles, fails at runtime. Arrays are covariant — a String[] is accepted
where an Object[] is wanted — and the JVM has to check every store to catch
the consequences.
Generics learned from that:
List<Object> everything = new ArrayList<String>();
error: incompatible types: ArrayList<String> cannot be converted to List<Object>
List<String> is not a List<Object>, even though String is an Object.
This is called invariance, and it is the single most confusing thing about
generics until you see why.
If the assignment were allowed, everything.add(42) would be perfectly legal —
everything is a List<Object>, after all — and you would have put an Integer
into a List<String>. The compiler refuses the first step so the second cannot
happen. Arrays allow the first step and pay for it with a runtime check.
That invariance is inconvenient often enough that Java provides an escape hatch, which is the wildcards lesson at the end of this module. Knowing why it exists makes that lesson short.
Type erasure, and what it costs
Generics are a compile-time feature. After type checking, the compiler erases the type arguments and inserts casts where needed. The bytecode has no idea your list was a list of strings:
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass());
System.out.println(a.getClass().getName());
true
java.util.ArrayList
Same class at runtime. This was done so that Java 5 code could interoperate with the billions of lines written before it, and it is the reason for every remaining oddity in this module.
Four consequences worth knowing, because each produces a puzzling error:
| You cannot | Why | What to do instead |
|---|---|---|
new T[n] |
The array needs a real type at runtime | (T[]) new Object[n], or use a List<T> |
o instanceof List<String> |
The type argument is gone | o instanceof List<?> |
new T() |
No constructor to call | Pass a Supplier<T> or a Class<T> |
Overload on List<String> and List<Integer> |
Both erase to List |
Give the methods different names |
error: generic array creation
return new T[n];
^
error: Object cannot be safely cast to List<String>
System.out.println(o instanceof List<String>);
^
The second message is worth reading closely: the compiler is not saying the test
is wrong, it is saying it cannot be checked. o instanceof List<?> compiles,
because that asks a question erasure can still answer.
One useful exception, since Java 16: x instanceof List<String> is allowed
when x is already statically known to be a List<String>, because then nothing
needs checking. That is why the same line compiles in one place and not another.
The vocabulary
Worth fixing now, because the rest of the module uses it.
| Term | Means | Example |
|---|---|---|
| Generic type | A type with a type parameter | List<E> |
| Type parameter | The placeholder in the declaration | E in interface List<E> |
| Type argument | The actual type supplied | String in List<String> |
| Raw type | A generic type used with no argument | List |
| Parameterised type | A generic type with its argument | List<String> |
| Bounded type parameter | A parameter with a restriction | <T extends Number> |
| Wildcard | An unknown type argument | List<?>, List<? extends Charge> |
And the conventional single-letter names, which you should follow because every Java reader expects them:
| Letter | Used for |
|---|---|
T |
Type |
E |
Element, in a collection |
K, V |
Key and value, in a map |
R |
Return type |
N |
Number |
S, U |
Second and third types |
What generics do not do
- They do not exist at runtime. You cannot ask a list what it holds.
- They do not work with primitives.
List<int>is illegal; it isList<Integer>, and every element is a boxed object. For large numeric data this matters, andIntStreamin module 6 is part of the answer. - They do not make code faster. The casts they remove from your source are still generated. The gain is correctness and readability.
- They do not replace validation.
List<String>guarantees strings, not non-empty, non-null, six-digit strings.
Check your work
What is a raw type, and what is wrong with it? A generic type used without a
type argument, like List. It compiles with warnings, allows anything to be
added, and converts a compile-time error into a ClassCastException somewhere
unrelated.
Where did the ClassCastException appear, relative to the mistake? In the
reading loop, not on the add(42) line that caused it. That distance is the
cost.
Why is List<Object> l = new ArrayList<String>() rejected? Because it would
then be legal to add an Integer through l, putting a non-String into a
List<String>. Generics are invariant so that cannot happen.
Why do arrays allow the equivalent? Arrays are covariant, a decision from
before generics existed. The JVM pays for it with a runtime check that throws
ArrayStoreException.
What is type erasure? The compiler checks types, then removes the type
arguments, so at runtime a List<String> and a List<Integer> are both just
ArrayList.
Name two things erasure prevents. new T[n] (generic array creation),
o instanceof List<String> on a variable not already known to be one, new T(),
and overloading on two parameterisations of the same type.
Why is List<int> illegal? Type arguments must be reference types. Use
List<Integer> and accept the boxing, or a primitive-specialised API.
Practice 2, the raw-type conversion. Adding <String> moves the failure from
a ClassCastException in the loop to no suitable method found for add(int) on
the offending line, and lets you delete the cast from the loop entirely. The
number of lines goes down and the number of bugs findable by the compiler goes
up.
Practice 4, the erasure experiments. a.getClass() == b.getClass() is
true; both print java.util.ArrayList. new T[n] gives generic array
creation. o instanceof List<String> on an Object gives
Object cannot be safely cast to List<String>, while o instanceof List<?>
compiles — because that is a question the runtime can still answer.
Practice
-
Reproduce the old world. Write the raw
Listexample, add a number to a list of names, and watch where theClassCastExceptionappears. Note how many lines separate the cause from the crash. -
Convert it. Add the type argument and recompile. Read the new error, then delete the cast in the loop and confirm it still compiles.
-
Turn warnings on. Compile the raw version with
javac -Xlint:alland read all five warnings. Then find out how to make your IDE show them permanently. Most raw types in real code exist because nobody had warnings on. -
Poke at erasure. Print
a.getClass() == b.getClass()for aList<String>and aList<Integer>. Then try to write a method returningnew T[n], and tryo instanceof List<String>whereois anObject. Read both errors, then make the second one compile. -
Prove the covariance difference. Write the
Object[] objects = new String[2]version and watch it throw at runtime. Then write theListequivalent and watch it fail to compile. Write one sentence on which you would rather debug. -
Harder — find a raw type in the wild. Open any Java project on GitHub that is more than ten years old and search for
new HashMap()orimplements Comparatorwithout brackets. Pick one, work out what type argument it should have, and write down what would have to be true for the missing one to cause a bug. This is the commonest real refactoring an intern is handed in their first month.
Next: writing your own generic classes and methods, rather than only consuming other people's.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship