Generics basics
Why Java generics exist, generic classes and methods, type inference, raw types, primitive limitations, and the invariance rule every interview expects.
On this page
🧰 What are generics in Java?
Generics let you write a class, interface or method with a type placeholder and let the caller fill in the real type. List<String> means "this list is meant to contain strings", so the compiler stops list.add(42) before the program runs. It acts like labelled storage bins in a warehouse: the bin shape is reusable, but the label says what belongs inside.
For this lesson, we'll focus on the everyday generics you need in interviews and code review: why Java added them, generic classes, interfaces and methods, type inference, the diamond operator, naming conventions, raw types, primitive type arguments, and the first rule that surprises people, invariance. We'll leave the deeper wildcard and bound mechanics for the next lessons.
Below the line (out of scope)
- Wildcards,
? extendsand? super: Wildcards and PECS - Type bounds and advanced generic methods: Bounded types and generic methods
- What survives at runtime after erasure: How type erasure works
- The collections that use generics heavily: How HashMap works
The 30-second answer
Generics are Java's compile-time type-safety system for reusable types. Before Java 5, collections were raw: you put objects into a List, cast on the way out, and found mistakes as ClassCastException at runtime. With List<String>, Box<T>, Pair<K,V> and generic methods like <T> T first(List<T>), javac checks the relationship between input and output types and inserts the casts for you. The diamond operator new ArrayList<>() lets the compiler infer the right type, and Collections.<String>emptyList() is the explicit form when inference needs help. Avoid raw types in new code, remember List<Integer> is not a List<Number>, and remember there is no List<int> because generic type arguments are reference types.
What you’ll learn
- Explain why generics replaced raw collections and casts with compile-time type safety
- Write generic classes, interfaces and methods with
T,E,K,VandRtype parameters - Use type inference, the diamond operator and an explicit type witness such as
Collections.<String>emptyList() - Recognize raw types and unchecked warnings, and know why new code should avoid them
- Explain why there is no
List<int>, when boxing costs matter, and whereIntStreamhelps - State the invariance rule:
List<Integer>is not aList<Number>, unlike arrays
Interview radar
Asked very often: Why do we need generics if Java already has Object?
Core follow-up: Why is List<Integer> not a List<Number>?
Production angle: A raw collection warning ignored during a refactor becomes a ClassCastException on a request path that was never covered by tests.
01Picture it first: labelled bins, not runtime cages
Think of a raw List as an unlabelled storage bin. Anyone can drop anything into it, so every reader has to open the bin, guess what's inside and cast by hand. A generic List<OrderId> is the same physical bin with a checked label on the front: the compiler guards the door while you're compiling, then the runtime mostly sees an ordinary List.
The important model is compile-time checking first, runtime casts second. Generics do not make a separate runtime class for List<String> and another for List<Integer>. They make your source safer before bytecode runs.
A raw list accepts both strings and integers and fails later at a cast, while a generic List rejects the integer at compile time and reads strings without a manual cast
Notice the timing. The raw version fails late, often far from the line that inserted the bad value. The generic version fails where the bug is introduced, which is exactly where you want the compiler to point.
02Generics under the hood: why Java needed them
Before Java 5, a List held Object. That was flexible, but it moved type mistakes from compilation to production.
Try it yourself first
Before reading on, find the line that creates the bug and the line that finally reports it.
import java.util.ArrayList;
import java.util.List;
class RawListDemo {
public static void main(String[] args) {
List statuses = new ArrayList(); // raw type, pre-Java 5 style
statuses.add("PAID");
statuses.add(404); // compiles, bug is planted here
for (Object value : statuses) {
String status = (String) value; // ClassCastException on 404
System.out.println(status.toLowerCase());
}
}
}
Now say the same thing with a type parameter:
import java.util.ArrayList;
import java.util.List;
class GenericListDemo {
public static void main(String[] args) {
List<String> statuses = new ArrayList<>();
statuses.add("PAID");
// statuses.add(404); // compile-time error
for (String status : statuses) {
System.out.println(status.toLowerCase());
}
}
}
Notice three things:
- The generic list gives
javacenough information to reject the wrongaddcall. - The loop variable is
String, notObject, so your code has no hand-written cast. - The runtime still has normal objects inside an
ArrayList; the safety is enforced before execution.
That last point is why generics are a compile-time feature rather than a new collection hierarchy. We cover the bytecode story in How type erasure works, but for this lesson the practical rule is enough: if the compiler can prove the type, you don't need a cast.
03Generic classes and interfaces
A generic class declares one or more type parameters after its name. The type parameter is scoped to the class body, including fields, constructors and methods.
Try it yourself first
Read the two classes first. Which type is fixed when you create the object, and which type is decided by each method call?
interface Repository<ID, E> {
E findById(ID id);
void save(E entity);
}
final class Box<T> {
private final T value;
Box(T value) {
this.value = value;
}
T value() {
return value;
}
}
record Pair<K, V>(K key, V value) { }
final class OrderRepository implements Repository<Long, String> {
@Override
public String findById(Long id) {
return "order-" + id;
}
@Override
public void save(String entity) {
System.out.println("saved " + entity);
}
}
Box<T> becomes Box<String> when you write new Box<>("paid"). Pair<K,V> becomes Pair<String, Integer> when you store a status and a count. Repository<ID,E> shows the interface version: an implementation chooses concrete types and must keep them consistent across every method.
The naming convention is short because these letters appear everywhere:
| Name | Usual meaning | Example |
|---|---|---|
T |
type | Box<T> |
E |
element | List<E>, Set<E> |
K |
key | Map<K,V> |
V |
value | Map<K,V> |
R |
result | Function<T,R> |
? |
unknown type | List<?> |
These names are conventions, not magic. If a type parameter has domain meaning, use a domain name sparingly, such as Repository<ID, ENTITY>. For public APIs, I still prefer the standard letters because Java developers parse them instantly.
04Generic methods, type inference and the diamond operator
A generic method declares its type parameter before the return type. The method's T belongs to the method call, not to the enclosing class.
Try it yourself first
In the code below, where does T come from for each call to first, singleton and emptyList?
import java.util.Collections;
import java.util.List;
final class GenericMethods {
static <T> T first(List<T> values) {
if (values.isEmpty()) throw new IllegalArgumentException("empty");
return values.get(0);
}
static <T> List<T> singleton(T value) {
return List.of(value);
}
public static void main(String[] args) {
String status = first(List.of("PAID", "PENDING"));
Integer code = first(List.of(200, 404));
List<String> oneStatus = singleton("PAID");
List<String> none = Collections.emptyList();
List<String> alsoNone = Collections.<String>emptyList();
var names = List.of("Mina", "Ravi");
System.out.println(status + " " + code + " " + oneStatus + " " + none + alsoNone + names);
}
}
Notice the placement: static <T> T first(...), not static T <T> first(...). The compiler usually infers T from the argument list, the assignment target or both. Collections.<String>emptyList() is called an explicit type witness; you rarely need it today, but it is useful when the target type is not obvious to the compiler or to a human reading the line.
The diamond operator, <>, is the constructor side of the same idea. Since Java 7, new ArrayList<>() can infer the type from the left side or the surrounding context. Since Java 9, diamond also works with anonymous classes when the inferred type is denotable:
import java.util.Comparator;
import java.util.Map;
import java.util.TreeMap;
class DiamondDemo {
public static void main(String[] args) {
Map<String, Integer> counts = new TreeMap<>();
counts.put("paid", 2);
Comparator<String> byLength = new Comparator<>() {
@Override
public int compare(String left, String right) {
return Integer.compare(left.length(), right.length());
}
};
System.out.println(byLength.compare("a", "bbb") < 0);
}
}
The simple option is to let inference work and keep the code quiet. It stops being enough when the inferred type is broader than you wanted, such as List<Number> versus List<Integer>, or when an API call returns an empty generic value. My default: use diamond and inference, then add an explicit target type or type witness only when it clarifies a real ambiguity.
The most common generics bug I see in code review is not exotic. Someone suppresses an unchecked warning while migrating old code, then a raw Map carries one non-String value into code that assumes Map<String, String>. The service works for months until a rare partner payload takes that path. The stack trace points at a cast during read, not at the raw write that caused it.
05Advanced: raw types, primitives and invariance
A raw type is a generic type used without its type arguments: List instead of List<String>, Map instead of Map<String, Integer>. Java keeps raw types for migration compatibility with pre-Java 5 code, but new code should treat them as a smell.
import java.util.ArrayList;
import java.util.List;
class RawTypeWarningDemo {
static void addWrongThing(List raw) {
raw.add(99); // unchecked call warning with -Xlint:unchecked
}
public static void main(String[] args) {
List<String> statuses = new ArrayList<>();
statuses.add("PAID");
addWrongThing(statuses);
String second = statuses.get(1); // ClassCastException at runtime
System.out.println(second);
}
}
Many candidates say raw types are useful when "the list can hold anything". That is what List<?> is for when you need to accept an unknown element type safely. A raw List turns off generic checking and gives you unchecked warnings; List<?> keeps the compiler involved and stops you from adding the wrong thing.
Generics also accept only reference types. List<int> is illegal, so List<Integer> boxes each int into an Integer object when a collection is the right abstraction. For a small list of 50 numbers, nobody cares. For 10 million counters in a tight loop, boxing means extra objects, more memory traffic and more GC pressure.
With compressed ordinary object pointers, an Integer object is typically 16 bytes, while an int in an int[] is 4 bytes. An ArrayList<Integer> also stores a 4-byte reference per slot, so the storage for 10 million boxed ints can be roughly 200 MB before list overhead, versus about 40 MB for an int[]. Use primitive arrays or IntStream when the data is numeric and hot; use collections when the API shape matters more. Project Valhalla is working toward specialized generics for value and primitive-like types, but Java 21 does not have List<int>.
Now the rule interviewers love: generic types are invariant. Even though Integer extends Number, List<Integer> does not extend List<Number>.
A List cannot be assigned to List because that would allow adding a Double, while a String array can be assigned to Object array and then fails at runtime with ArrayStoreException
Try it yourself first
Predict which line fails at compile time and which line fails at runtime.
import java.util.ArrayList;
import java.util.List;
class InvarianceDemo {
public static void main(String[] args) {
List<Integer> ints = new ArrayList<>(List.of(1, 2, 3));
// List<Number> nums = ints; // compile-time error: invariant
List<? extends Number> readOnlyNums = ints;
Number first = readOnlyNums.get(0);
System.out.println(first);
String[] strings = {"paid"};
Object[] objects = strings; // arrays are covariant
objects[0] = 42; // ArrayStoreException at runtime
}
}
Arrays chose runtime checking: String[] is an Object[], but the array remembers its real component type and throws ArrayStoreException if you store an Integer. Generics chose compile-time checking: List<Integer> is not a List<Number>, so the bad store is rejected before it can happen. The wildcard line, List<? extends Number>, is the safe read-only shape, and we cover that properly in Wildcards and PECS.
A good default is boring: use List<T> when you both read and write exactly T, use List<?> when you only need to inspect unknown values, and reach for wildcards only when an API needs flexibility. If you find yourself fighting the compiler with casts, pause. The type relationship is probably trying to tell you something.
06How to say it in the interview
When I ask about generics, I'm listening for four things: you know the pre-Java 5 raw collection problem, you can write a generic class or method without guessing the syntax, you avoid raw types and unchecked warnings, and you can explain invariance without hand-waving. If you also mention boxing costs and type erasure as the next layer down, you're in good shape.
In the interview
"Generics let me express relationships between types at compile time. Before Java 5, a List held Object, so I had to cast on reads and I could accidentally mix types until a ClassCastException happened later. With List<T> T first(List<T>). I avoid raw types in new code because they disable checking and produce unchecked warnings. Generics work with reference types, not primitives, so List
On the whiteboard
Generics = compile-time type relationships
pre-Java 5: raw List -> Object reads -> casts -> ClassCastException later
List<String>: wrong add rejected, reads are String
class Box<T>, record Pair<K,V>, interface Repository<ID,E>
generic method: static <T> T first(List<T> xs)
inference: new ArrayList<>(), Collections.<String>emptyList()
names: T type, E element, K key, V value, R result, ? unknown
raw List: migration only, unchecked warnings -> avoid in new code
no List<int>: use List<Integer>, int[], IntStream; boxing can matter
invariant: List<Integer> != List<Number>
arrays covariant: String[] -> Object[] -> ArrayStoreException
next: PECS and bounded methods