You’ve been using generics since module 04 — every ArrayList<String> and HashMap<String, Integer> you typed had them. This module is where you stop copying the angle brackets and start understanding them. The payoff: you’ll read library code without fear, and JpaRepository<User, Long> in Spring will look obvious instead of alien.
The Goal
By the end of this module you can:
- Explain the exact problem generics solve, and why a compile-time error beats a runtime crash
- Read any generic signature out loud —
Map<String, List<Expense>>should sound like a sentence, not noise - Write your own generic class and generic methods, including bounded ones
- Recognize
? extendsand? superwildcards when they show up in library code - Connect generics to the collections you already use and the Spring code you’ll write later
The Lesson
The problem: code that compiles fine and explodes at runtime
Before Java 5, collections held Object. Anything could go in, and you had to cast on the way out. Here’s that world:
import java.util.ArrayList;
import java.util.List;
public class RawListDemo {
public static void main(String[] args) {
List names = new ArrayList(); // raw list — it holds Object, so ANYTHING goes in
names.add("Darshan");
names.add(42); // compiler shrugs. It shouldn't.
for (Object o : names) {
String name = (String) o; // you PROMISE the compiler this is a String
System.out.println(name.toUpperCase());
}
}
}
This compiles with only a warning. Run it:
DARSHAN
Exception in thread "main" java.lang.ClassCastException:
class java.lang.Integer cannot be cast to class java.lang.String
The bug was on the names.add(42) line, but the crash happens later, somewhere else, possibly months later in production when a user does something nobody tested. That gap — bug here, crash there — is what makes runtime errors expensive.
Now the same code with generics:
List<String> names = new ArrayList<>();
names.add("Darshan");
names.add(42); // COMPILE ERROR: incompatible types — caught before the code ever runs
The wrong line is now the line that errors. You fix it in ten seconds, in your editor, before anyone sees it. That is the entire point of generics: move the explosion from runtime to compile time.
flowchart LR
A[Raw list takes wrong type] --> B[Compiles fine]
B --> C[Ships to production]
C --> D[Crashes on a real user]
E[Generic list takes wrong type] --> F[Red line in your editor]
F --> G[Fixed in ten seconds]
Bonus: the cast disappears too. String name = names.get(0); — no (String), because the compiler already knows.
Reading generic signatures out loud
This is a daily-job skill. Every javadoc, every library method, every Spring interface is written in this language. Read them as sentences:
| Signature | Say it as |
|---|---|
List<String> | ”a list of strings” |
Map<String, Integer> | ”a map from string to integer” |
List<List<String>> | ”a list of lists of strings” |
Map<String, List<Expense>> | ”a map from string to a list of expenses” |
Optional<User> | ”maybe a user” |
JpaRepository<User, Long> | ”a repository of users, whose id type is long” |
If you can say the sentence, you understand the type. When a signature feels unreadable, read it inside-out: innermost type first.
Writing a generic class
A generic class has one or more type parameters — placeholders that the user of the class fills in. By convention: T for type, E for element, K/V for key/value, A/B for pairs.
public class Box<T> {
private T value; // T is whatever the user said it is
public void put(T value) {
this.value = value;
}
public T get() {
return value; // no cast needed — the compiler tracks T
}
}
Use it:
Box<String> nameBox = new Box<>(); // T = String, for this box only
nameBox.put("Darshan");
String name = nameBox.get(); // compiler KNOWS this is a String
Box<Integer> ageBox = new Box<>(); // same class, T = Integer here
ageBox.put(24);
One class, infinite type-safe variations. Two type parameters works the same way:
public class Pair<A, B> {
private final A first;
private final B second;
public Pair(A first, B second) {
this.first = first;
this.second = second;
}
public A first() { return first; }
public B second() { return second; }
}
Pair<String, Integer> upiTxn = new Pair<>("chai stall", 20);
Hold onto Pair<A, B> — Spring’s JpaRepository<User, Long> is exactly this shape: two type parameters, entity and id.
Generic methods
You don’t need a generic class to write a generic method. Declare the type parameter in angle brackets before the return type:
public class ListUtils {
// The <T> before List<T> declares T for this method only
public static <T> T firstOrNull(List<T> list) {
return list.isEmpty() ? null : list.get(0);
}
}
String first = ListUtils.firstOrNull(List.of("a", "b")); // T inferred as String
Integer num = ListUtils.firstOrNull(List.of(1, 2, 3)); // T inferred as Integer
You almost never spell out T at the call site — the compiler infers it from the arguments.
Bounded types: when “any type” is too loose
Say you want a max() that works for any list. Problem: with plain T, the compiler only lets you call Object methods on T — it has no idea T can be compared. The fix is a bound:
import java.util.List;
public class MathUtils {
// Read aloud: "any type T that knows how to compare itself to other Ts"
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) { // legal ONLY because of the bound
best = item;
}
}
return best;
}
public static void main(String[] args) {
System.out.println(max(List.of(45, 12, 99, 7))); // 99
System.out.println(max(List.of("mango", "apple", "zen"))); // zen
}
}
<T extends Comparable<T>> is a deal: “I’ll accept any type, as long as it implements Comparable.” In exchange, the compiler lets you call compareTo inside the method. Try max(List.of(new Object())) — compile error, because Object didn’t sign the deal.
Wildcards: recognize, don’t memorize
You’ll see ? in library signatures constantly. For now, just learn to read them — don’t try to memorize the PECS rule yet:
| You see | It means |
|---|---|
List<? extends Number> | ”a list of Number or any subtype — safe to read Numbers out, not safe to add” |
List<? super Integer> | ”a list of Integer or any supertype — safe to add Integers into” |
Real example from the JDK: boolean addAll(Collection<? extends E> c) — “give me a collection of E or anything more specific.” That’s why you can add a List<Integer> into a List<Number>’s addAll. When wildcards confuse you in the wild, translate them to one of the two sentences above and move on.
Type erasure in 3 lines
- Generics exist only at compile time — the compiler checks everything, then erases
TtoObjectin the bytecode. - At runtime, a
List<String>and aList<Integer>are the same class: justList. - That’s why
new T()andnew T[10]are illegal — at runtime,Tdoesn’t exist to construct.
flowchart LR
A[Your source with List of String] --> B[Compiler checks every type]
B --> C[Bytecode with plain List]
C --> D[JVM runs it with no generics at all]
Erasure is why generics feel like a compiler feature rather than a JVM feature — because that’s exactly what they are.
Where this goes next
Everything you just learned is already in code you use:
| You already use | What it really is |
|---|---|
ArrayList<E> | a generic class, like your Box<T> |
Map<K, V> | two type parameters, like your Pair<A, B> |
Collections.max(list) | a bounded generic method, like your max |
JpaRepository<User, Long> (Spring, soon) | a generic interface — entity type and id type |
When you reach Spring Boot and write interface UserRepository extends JpaRepository<User, Long>, nothing on that line will be new. You built it today.
Build This
You’re building InMemoryRepository<T> — a baby version of what Spring Data gives you for free later. One class, reused for two completely different types.
- Make a folder and enter it:
mkdir generics-practice
cd generics-practice
- Create
InMemoryRepository.java:
import java.util.ArrayList;
import java.util.List;
public class InMemoryRepository<T> {
private final List<T> items = new ArrayList<>();
public T save(T item) {
items.add(item);
return item; // returning the saved item mirrors Spring Data
}
public List<T> findAll() {
return new ArrayList<>(items); // a copy — callers can't mutate our internals
}
public long count() {
return items.size();
}
}
- Create two record types —
Expense.javaandFriend.java:
public record Expense(String description, int amountInRupees) {}
public record Friend(String name, String city) {}
- Create
Main.javaand use ONE repository class for BOTH types:
import java.util.List;
public class Main {
public static void main(String[] args) {
InMemoryRepository<Expense> expenses = new InMemoryRepository<>();
expenses.save(new Expense("chai", 20));
expenses.save(new Expense("mobile recharge", 239));
expenses.save(new Expense("auto to office", 60));
InMemoryRepository<Friend> friends = new InMemoryRepository<>();
friends.save(new Friend("Arjun", "Bengaluru"));
friends.save(new Friend("Priya", "Chennai"));
System.out.println("Expenses tracked: " + expenses.count());
List<Expense> all = expenses.findAll();
int total = all.stream().mapToInt(Expense::amountInRupees).sum();
System.out.println("Total spent: Rs " + total);
System.out.println("Friends saved: " + friends.count());
}
}
- Compile and run:
javac *.java
java Main
-
Now break it on purpose: add
expenses.save(new Friend("Ravi", "Pune"));and recompile. Read the compile error carefully — that red line is generics doing its one job. Delete the line after. -
Stretch: add a
findFirst()method to the repository that returnsT, and a static generic methodprintAll(List<?> items)in Main.
Where to Practice
| Resource | What to do there | How long |
|---|---|---|
| dev.java | Read the Generics section of the official tutorial — skim wildcards, focus on classes and methods | 30 min |
| exercism.org Java track | Do 2-3 exercises that use collections; write your own generic helper where it fits | 45 min |
| HackerRank Java | The Java Generics challenge — short, forces you to write a generic method from scratch | 15 min |
| Baeldung | Search “java generics” — read after building, as a recap, not before | 20 min |
How to Practice
- Type everything. The angle-bracket syntax only sticks through your fingers.
- Never copy-paste the snippets — retype them, including the errors you make.
- Time-box to 2 hours. Generics rabbit-holes (wildcards, erasure edge cases) can eat days. Don’t let them yet.
- Break it on purpose. Add wrong types, remove bounds, use raw types — read every compile error you cause.
Check Yourself
- What problem do generics solve, in one sentence about where the error happens?
- Read
Map<String, List<Integer>>out loud. - In
Box<T>, who decides whatTactually is, and when? - What does
<T extends Comparable<T>>buy you inside the method body? - What’s the difference between a generic class and a generic method?
- You see
List<? extends Number>in a library. What can you safely do with it? - Why can’t you write
new T()inside a generic class? - How does
JpaRepository<User, Long>relate to thePair<A, B>you wrote?
Answers
- Generics move type errors from runtime (a crash in production) to compile time (a red line in your editor).
- “A map from string to a list of integers.”
- The user of the class decides, at the moment they write
new Box<String>()— each instantiation can pick a different type. - The right to call
compareToonT— the bound guarantees every accepted type implementsComparable. - A generic class declares
Ton the class and every instance carries it; a generic method declares<T>before its return type andTlives only for that one call. - Safely read elements out as
Number. You can’t add to it (exceptnull), because the actual list might beList<Double>and you don’t know. - Type erasure — at runtime
Thas been erased toObject, so there’s no real class for the JVM to construct. - Same shape: a generic interface with two type parameters — the entity type (
User) and its id type (Long). You already built a one-parameter version of it.
Explain it out loud: Without looking at this page, explain to an imaginary junior why List<String> is better than a raw List, and what the compiler does with the <String> part at compile time versus runtime. If you stumble on the runtime part, reread the erasure section.
Still Unclear?
Copy-paste any of these to Claude:
- “I wrote a generic class
Box<T>in Java. Walk me through exactly what the compiler does differently forBox<String>vsBox<Integer>, and what the JVM sees at runtime. Keep it under 10 lines.” - “Explain
<T extends Comparable<T>>like I’m a beginner. Why is theTrepeated insideComparable<T>? Give me one example that compiles and one that fails because of the bound.” - “Show me 3 real method signatures from the Java standard library that use
? extendsor? super, and translate each one into a plain English sentence.”
Why AI Can’t Do This For You
AI will happily write generic classes all day. But you’re the one reading — javadocs, stack traces, teammates’ code, Spring source. If Map<String, List<Order>> doesn’t parse instantly in your head, every codebase is fog, and you can’t even tell whether the AI’s output is right. Reading speed is the skill, and it only comes from your own reps.
Module done? Add it to today’s tracker