Java generics let you say "a box that holds strings" or "a list of invoices" instead of "a box that holds objects", and the compiler checks those promises for you. Without generics you store everything as Object, cast it back on the way out, and find out about a mistake only when the cast fails at runtime. With generics, the mistake is a compile error.
What goes wrong when everything is plain Object?
Before generics, a container held Object references, so any reference type could go in and any reference type could come out. The compiler could not know what would come out, so it made you cast - and a cast that fails throws ClassCastException at the moment it runs, not at the moment you made the mistake.
import java.util.ArrayList;
import java.util.List;
public class RawBox {
public static void main(String[] args) {
List box = new ArrayList();
box.add("a string");
box.add(42);
Object first = box.get(0);
System.out.println("first as Object: " + first);
String text = (String) first;
System.out.println("cast worked: " + text);
Object second = box.get(1);
try {
String wrong = (String) second;
System.out.println(wrong);
} catch (ClassCastException e) {
System.out.println("cast failed: " + e.getMessage());
}
}
}first as Object: a string
cast worked: a string
cast failed: 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')The program put a string and an integer into the same list and then had to guess what each element was. It guessed right for the first element and wrong for the second, and the wrong guess produced a runtime exception. Notice that nothing about the code looked dangerous when it was written: adding a number to a list of strings is exactly the kind of mistake that a compiler should catch.
How do you declare a generic class?
Add a type parameter in angle brackets after the class name, and use it wherever you would have written Object. The letter is usually a single capital, and T is the conventional choice for "any type". When you create an instance you choose the actual type, and the compiler enforces that choice for every use of that object.
public class GenericBox<T> {
private T value;
public GenericBox(T value) {
this.value = value;
}
public T get() {
return value;
}
public void set(T value) {
this.value = value;
}
public static void main(String[] args) {
GenericBox<String> names = new GenericBox<>("ana");
GenericBox<Integer> counts = new GenericBox<>(7);
System.out.println("name : " + names.get());
System.out.println("count : " + counts.get());
names.set("bo");
System.out.println("name after set : " + names.get());
}
}name : ana
count : 7
name after set : boThe diamond operator - the empty angle brackets in new GenericBox<>("ana") - lets the compiler infer the type argument from the arguments, so you rarely need to write it out twice. The guarantee now holds at every line: names.get() returns a String, and there is no cast anywhere in the program. If you tried to put an integer into names, the build would fail with a message naming the exact line.
How do you write generic methods?
A method can be generic on its own, without a generic class. Put the type parameter before the return type, and the compiler infers it from the arguments at the call site. This is how the standard library writes methods such as Collections.emptyList() and List.of().
import java.util.List;
public class GenericMethods {
static <T> T firstOf(List<T> items) {
if (items.isEmpty()) {
return null;
}
return items.get(0);
}
static <T> List<T> listOf(T... values) {
return List.of(values);
}
public static void main(String[] args) {
List<String> names = List.of("ana", "bo");
List<Integer> numbers = List.of(1, 2, 3);
System.out.println("first name : " + firstOf(names));
System.out.println("first number : " + firstOf(numbers));
System.out.println("list of names : " + listOf("x", "y"));
System.out.println("list of ints : " + listOf(1, 2));
}
}first name : ana
first number : 1
list of names : [x, y]
list of ints : [1, 2]Look at how the same method serves two different types. firstOf(names) returns a String and firstOf(numbers) returns an Integer, and the compiler knows which is which at each call site. The varargs version listOf(T... values) is the same trick: one method, any element type, and the result type follows the arguments.
What is type erasure?
Here is the part that surprises people. Generics are a compile-time feature. The compiler checks the types, adds casts where they are needed, and then erases the type information from the class file. At runtime a GenericBox<String> and a GenericBox<Integer> are the same class.
import java.util.List;
public class ErasureDemo {
public static void main(String[] args) {
List<String> strings = List.of("a");
List<Integer> numbers = List.of(1);
System.out.println("same runtime class : " + (strings.getClass() == numbers.getClass()));
System.out.println("class of a list : " + strings.getClass().getName());
System.out.println("generic type of strings : " + strings.getClass().getGenericInterfaces().length);
Object raw = strings;
System.out.println("raw add compiles and works : " + raw);
}
}same runtime class : true
class of a list : java.util.ImmutableCollections$List12
generic type of strings : 1
raw add compiles and works : [a]That is why you cannot write if (box instanceof GenericBox<String>) - the pattern is illegal, because there is no runtime type to test against. You can test against the raw type, but then you are back to casting. The practical consequences are: generic type arguments cannot be instantiated directly (new T() is illegal), you cannot use a primitive as a type argument (List<int> is illegal, though List<Integer> works), and reflection sees the erased type unless you ask for the generic signature.
What happens if you skip the type argument?
You get a raw type, and every safety net disappears. A raw List accepts anything, returns Object, and only complains when a cast fails at runtime. Raw types exist for compatibility with code written before generics, and they generate warnings in modern builds.
| Choice | Compile-time safety | Cost |
|---|---|---|
List<String> | Wrong type is a compile error | None |
Raw List | Wrong type is a runtime exception | Warnings, and a cast everywhere |
List<?> | Accepts anything, returns nothing specific | You cannot add elements |
Choosing between those three is the subject of the next lesson, where bounds and wildcards decide how much freedom a generic API leaves its caller.
The habits to keep
- Always write the type argument, or let the compiler infer it with the diamond operator.
- Never use a raw type in new code - it turns a compile error into a runtime crash.
- Put the type parameter on the method when the class does not need it.
- Remember that generics do not exist at runtime: erasure explains most of their limits.
- Use
List<Integer>and notList<int>, and expect autoboxing to bridge the gap.
Try it yourself: write a generic Pair<A, B> class holding two values with a getFirst and a getSecond method and a swap method that returns a new Pair<B, A>. Instantiate it once with two strings and once with a string and an integer, then write a small static method that takes a List<Pair<String, Integer>> and returns the sum of the second values. Try to add a Pair<String, String> to that list and read the compiler error.