Career OS

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 ? extends and ? super wildcards 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:

SignatureSay 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 seeIt 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

  1. Generics exist only at compile time — the compiler checks everything, then erases T to Object in the bytecode.
  2. At runtime, a List<String> and a List<Integer> are the same class: just List.
  3. That’s why new T() and new T[10] are illegal — at runtime, T doesn’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 useWhat 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.

  1. Make a folder and enter it:
mkdir generics-practice
cd generics-practice
  1. 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();
    }
}
  1. Create two record types — Expense.java and Friend.java:
public record Expense(String description, int amountInRupees) {}
public record Friend(String name, String city) {}
  1. Create Main.java and 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());
    }
}
  1. Compile and run:
javac *.java
java Main
  1. 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.

  2. Stretch: add a findFirst() method to the repository that returns T, and a static generic method printAll(List<?> items) in Main.

Where to Practice

ResourceWhat to do thereHow long
dev.javaRead the Generics section of the official tutorial — skim wildcards, focus on classes and methods30 min
exercism.org Java trackDo 2-3 exercises that use collections; write your own generic helper where it fits45 min
HackerRank JavaThe Java Generics challenge — short, forces you to write a generic method from scratch15 min
BaeldungSearch “java generics” — read after building, as a recap, not before20 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

  1. What problem do generics solve, in one sentence about where the error happens?
  2. Read Map<String, List<Integer>> out loud.
  3. In Box<T>, who decides what T actually is, and when?
  4. What does <T extends Comparable<T>> buy you inside the method body?
  5. What’s the difference between a generic class and a generic method?
  6. You see List<? extends Number> in a library. What can you safely do with it?
  7. Why can’t you write new T() inside a generic class?
  8. How does JpaRepository<User, Long> relate to the Pair<A, B> you wrote?
Answers
  1. Generics move type errors from runtime (a crash in production) to compile time (a red line in your editor).
  2. “A map from string to a list of integers.”
  3. The user of the class decides, at the moment they write new Box<String>() — each instantiation can pick a different type.
  4. The right to call compareTo on T — the bound guarantees every accepted type implements Comparable.
  5. A generic class declares T on the class and every instance carries it; a generic method declares <T> before its return type and T lives only for that one call.
  6. Safely read elements out as Number. You can’t add to it (except null), because the actual list might be List<Double> and you don’t know.
  7. Type erasure — at runtime T has been erased to Object, so there’s no real class for the JVM to construct.
  8. 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 for Box<String> vs Box<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 the T repeated inside Comparable<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 ? extends or ? 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

Saves your progress on this device.