Just added: Algorithms you can run and practice
Updated Jun 22, 2026 Edit on GitHub

Generics, Deep - Type Safety Without Duplication

Back in Phase 3 you wrote List<String> and moved on - strings in, strings out, no casting. That <String> was your first taste of generics.

The mental model for this phase: generics let you write one piece of code that works for many types while the compiler still checks every type for you. Before generics, you got reuse or safety, never both. Generics give you both, and pay for it with a single weird runtime tax called type erasure. Once you understand that trade, the confusing parts - wildcards, the compiler's errors - become consequences instead of arbitrary rules.

Why generics exist - the pain they replaced

Before Java 5, collections held Object - the universal supertype every class extends. A list could hold anything, which sounds flexible until you try to get something back out.

import java.util.ArrayList;
import java.util.List;

// Pre-generics style: a raw list holds Object.
List names = new ArrayList();
names.add("Ada");
names.add("Grace");
names.add(42);                  // oops - nothing stops this

// Getting a value back gives you an Object. You must cast.
String first = (String) names.get(0);   // fine
String third = (String) names.get(2);   // 42 is not a String...
Exception in thread "main" java.lang.ClassCastException:
  class java.lang.Integer cannot be cast to class java.lang.String

What just happened: the raw List accepted a String, a String, and an Integer without complaint - to it, they're all just Object. The trouble surfaced at names.get(2): the cast promised a String, the promise was a lie, and the JVM threw ClassCastException at runtime, after the bug shipped.

Now the same idea with generics:

import java.util.ArrayList;
import java.util.List;

List<String> names = new ArrayList<>();
names.add("Ada");
names.add(42);                  // compile error - caught before you run
error: incompatible types: int cannot be converted to String
        names.add(42);
                  ^

What just happened: List<String> told the compiler "this list holds strings, full stop." The bad add(42) was rejected at compile time - the program never even built. And since the compiler knows the element type, names.get(0) hands you a String directly, no cast required. 💡 A compile error is a gift - it's a runtime crash caught early, when it's cheap to fix.

Generic methods and classes - <T> is a parameter for types

The <String> you've been writing is using a generic type. Now you'll write one. A type parameter is a placeholder for a type, written in angle brackets, filled in at the call site - exactly like a regular parameter is a placeholder for a value.

📝 Type parameter - a stand-in name (conventionally a single uppercase letter: T for "type," E for "element," K/V for "key"/"value") for a type the caller will supply, letting one definition serve every type.

A generic method declares its type parameter before the return type:

import java.util.List;

// <T> says "this method introduces a type parameter named T."
// It works for a List of ANY type, returning that same type.
static <T> T first(List<T> list) {
    return list.get(0);
}

public static void main(String[] args) {
    List<String> words = List.of("alpha", "beta");
    List<Integer> nums = List.of(10, 20, 30);

    String w = first(words);    // T inferred as String
    int n = first(nums);        // T inferred as Integer
    System.out.println(w + " " + n);
}
alpha 10

What just happened: <T> introduced a type parameter; T stands for whatever type the list holds, and the return type T means "same type I received." You never wrote <String> or <Integer> at the call sites - the compiler performed type inference off the argument, so first(words) made T be String and w needs no cast.

A generic class puts the type parameter on the class itself, so every instance is bound to a chosen type:

// Box<T>: a container holding exactly one value of some type T.
class Box<T> {
    private final T value;

    Box(T value) {
        this.value = value;
    }

    T get() {
        return value;
    }
}

public static void main(String[] args) {
    Box<String> nameBox = new Box<>("Ada");   // T = String for this box
    Box<Integer> ageBox = new Box<>(36);      // T = Integer for this box

    String name = nameBox.get();              // no cast - compiler knows it's String
    System.out.println(name + " is " + ageBox.get());
}

What just happened: class Box<T> declared T once, and the whole class body - field, constructor, return type - could use it. new Box<>("Ada") (the <> "diamond") let the compiler infer T as String, so nameBox.get() returns a String directly; ageBox is a separate binding where T is Integer. List<E>, Optional<T>, and Map<K, V> are built the same way.

Bounded type parameters - "any type, as long as"

A bare <T> means "literally any type" - too generous if your method needs to compare, add, or call a method on T, since not every type supports that. A bounded type parameter narrows the allowed types and unlocks the operations that bound guarantees.

📝 Bound - a constraint of the form <T extends SomeType> meaning "T must be SomeType or a subtype of it." It restricts which types the caller may use and lets the method body rely on everything SomeType provides. (extends here means "is-a," covering both extending classes and implementing interfaces.)

The classic case: finding the maximum of a list. To compare two Ts, each must know how to compare itself - exactly what Comparable promises:

import java.util.List;

// T must implement Comparable<T> - i.e. T values can be compared to each other.
static <T extends Comparable<T>> T max(List<T> list) {
    T biggest = list.get(0);
    for (T item : list) {
        if (item.compareTo(biggest) > 0) {   // legal ONLY because of the bound
            biggest = item;
        }
    }
    return biggest;
}

public static void main(String[] args) {
    System.out.println(max(List.of(3, 9, 2, 7)));        // Integer is Comparable
    System.out.println(max(List.of("pear", "fig", "kiwi"))); // String is Comparable
}
9
pear

What just happened: <T extends Comparable<T>> reads "for any type T that can be compared to itself" - what makes item.compareTo(biggest) legal; without it, T might have no compareTo. Integer and String both implement Comparable, so both work. The bound does double duty: keeps out incomparable types, grants the method the right to compare.

A bound that isn't met stops the compiler cold:

// A plain class that does NOT implement Comparable.
class Widget {}

static <T extends Number> double sum(List<T> list) {
    double total = 0;
    for (T item : list) {
        total += item.doubleValue();   // doubleValue() comes from Number
    }
    return total;
}

public static void main(String[] args) {
    sum(List.of(1, 2, 3));                 // fine: Integer extends Number
    sum(List.of(new Widget(), new Widget())); // bound violated
}
error: method sum in class Demo cannot be applied to given types;
  required: List<T>
  found:    List<Widget>
  reason: inference variable T has incompatible bounds
    upper bounds: Number
    Widget is not within its upper bound

What just happened: <T extends Number> only admits Number and its subtypes (Integer, Double, Long, ...), making item.doubleValue() safe. Integer satisfies the bound, so the first call compiles; Widget doesn't extend Number, so the second is rejected before the program runs - "Widget is not within its upper bound."

Wildcards and PECS - the part everyone trips on

⚠️ This section trips people up - not because the rule is hard, but because the reason behind it is unintuitive. We'll build the intuition, not just hand you the mnemonic.

You'd think List<Integer> is a kind of List<Number>, since an Integer is a Number. It is not:

import java.util.List;

List<Integer> ints = List.of(1, 2, 3);
List<Number> nums = ints;        // does NOT compile
error: incompatible types: List<Integer> cannot be converted to List<Number>

What just happened: generics are invariant - List<Integer> and List<Number> are unrelated types even though Integer extends Number. If it were allowed, nums.add(3.14) would look fine (a Double is a Number) but would stuff a Double into a list the rest of your code believes holds only Integers. Invariance stops that.

But invariance is sometimes too strict - a method that sums a list of numbers should accept List<Integer>, List<Double>, and List<Long> alike. Wildcards (?) restore that flexibility safely, in two mirror-image flavors.

📝 Upper-bounded wildcard ? extends T - "some specific subtype of T, but I don't know which." You can read Ts out of it. Lower-bounded wildcard ? super T - "some specific supertype of T, but I don't know which." You can write Ts into it.

? extends T makes a collection a producer - a source you read from:

import java.util.List;

// Accepts a List of ANY subtype of Number - reads values out and sums them.
static double sumAll(List<? extends Number> list) {
    double total = 0;
    for (Number n : list) {          // reading as Number is always safe
        total += n.doubleValue();
    }
    return total;
}

public static void main(String[] args) {
    System.out.println(sumAll(List.of(1, 2, 3)));        // List<Integer> - OK!
    System.out.println(sumAll(List.of(1.5, 2.5)));       // List<Double>  - OK!
}

What just happened: List<? extends Number> means "a list of some unknown subtype of Number" - loose enough to accept both List<Integer> and List<Double>. Reading is safe: whatever the real element type is, it's some kind of Number, so pulling items out as Number always works.

You cannot add to a ? extends collection - the famous head-scratcher:

static void brokenAdd(List<? extends Number> list) {
    list.add(42);    // does NOT compile
}
error: incompatible types: int cannot be converted to CAP#1
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Number from capture of ? extends Number

What just happened: the compiler refuses list.add(42) because it doesn't know the real element type - list might be a List<Double>, and an Integer would corrupt it. With ? extends, the unknown type sits on the output side: take Numbers out, put nothing in (except null) - read-only from the caller's view.

The mirror image is ? super T, which makes a collection a consumer - a sink you write into:

import java.util.ArrayList;
import java.util.List;

// Accepts any list that can HOLD an Integer: List<Integer>, List<Number>, List<Object>.
static void addThree(List<? super Integer> sink) {
    sink.add(1);     // safe: a List<Number> or List<Object> can hold an Integer
    sink.add(2);
    sink.add(3);
}

public static void main(String[] args) {
    List<Number> nums = new ArrayList<>();
    addThree(nums);              // works - Number is a supertype of Integer
    System.out.println(nums);
}
[1, 2, 3]

What just happened: List<? super Integer> means "Integer or any supertype." Writing Integers in is always safe - a List<Number> or List<Object> can hold one. Reading is restricted: the best the compiler can promise is Object, since the real list might hold any supertype. The unknown type sits on the input side: put Integers in, can't pull specific types out.

That symmetry has a mnemonic the whole Java world uses:

📝 PECS - Producer Extends, Consumer Super. If a parameter produces values you'll read, use ? extends T. If it consumes values you'll write, use ? super T. (Pulling fruit out of a basket? The basket is a producer → extends. Dropping fruit into it? Consumer → super.)

💡 Key point. The wildcard puts the "I don't know exactly which type" on whichever side is unsafe. ? extends doesn't know what you'd be adding, so it bans adds; ? super doesn't know what you'd be reading, so it bans typed reads - the minimum restrictions that keep you from corrupting a collection whose real type you can't see.

Type erasure - generics are a compile-time ghost

This explains a whole category of "wait, why can't I do that?" errors. All the type safety above happens at compile time. Once the compiler is satisfied, it throws the type information away - at runtime, the generics are gone.

📝 Type erasure - the compiler uses type parameters to check your code, then erases them, replacing each T with its bound (or Object if unbounded). The resulting bytecode has no generics at all - List<String> and List<Integer> compile down to the same plain List.

Watch the erasure with a runtime class check:

import java.util.ArrayList;
import java.util.List;

public static void main(String[] args) {
    List<String> strings = new ArrayList<>();
    List<Integer> integers = new ArrayList<>();

    // At runtime, both are just "ArrayList" - the <String>/<Integer> is gone.
    System.out.println(strings.getClass() == integers.getClass());
}
true

What just happened: List<String> and List<Integer> are different types to the compiler, which kept your strings and integers from mixing. But getClass() asks the runtime, which sees only ArrayList for both - the type parameter was erased after doing its job.

This single fact explains a cluster of restrictions that otherwise look arbitrary:

class Box<T> {
    T makeOne() {
        return new T();      // does NOT compile
    }
}
error: type parameter T cannot be instantiated directly
        return new T();
               ^

What just happened: you can't write new T() because at runtime there is no T - it's erased to Object, and the JVM wouldn't know which constructor to call. The same erasure forbids new T[10] (no generic array creation) and makes this illegal:

class Printer {
    void print(List<String> items) {}    // erases to print(List)
    void print(List<Integer> items) {}   // ALSO erases to print(List) - clash!
}
error: name clash: print(List<String>) and print(List<Integer>)
  have the same erasure

What just happened: overloading print on List<String> versus List<Integer> fails because both erase to print(List) - identical in bytecode though distinct in source. The "same erasure" error.

💡 Key point. Generics protect you at compile time, then vanish. Whenever the compiler complains about T at runtime-shaped operations (new, arrays, instanceof, overloading), ask "what is T after erasure?" The answer - usually Object - explains the restriction. Generics are a compile-time fiction the compiler maintains for you; the JVM never sees them.

Recap

  1. Generics move type safety from runtime to compile time. Before them, collections held Object and forced casts that blew up as ClassCastException; List<String> makes the same mistake a compile error instead.
  2. Type parameters (<T>) are placeholders for types. A generic method declares <T> before its return type; a generic class (Box<T>) declares it on the class. The compiler infers the type at the call site.
  3. Bounded type parameters (<T extends Comparable<T>>, <T extends Number>) restrict which types are allowed and unlock the operations that bound guarantees - the body can call compareTo or doubleValue only because the bound promised them.
  4. Wildcards fix invariance safely. PECS - Producer Extends, Consumer Super: read from ? extends T (no adding), write to ? super T (no reading specific types). The unknown type goes on whichever side would be unsafe.
  5. Type erasure means generics are compile-time only - T becomes its bound (usually Object) in the bytecode, so List<String> and List<Integer> are the same class at runtime. That's why you can't write new T(), new T[], or overload on erased types.

You can now read other people's generic signatures, write your own, and decode the compiler's type complaints instead of guessing. Next: lambdas and functional interfaces - passing behavior around as values, which leans on generics constantly (Function<T, R>, Predicate<T>) to stay type-safe.

Quick check

Test yourself on the three ideas that matter most - bounds, PECS, and erasure:

[
  {
    "q": "Why does `static <T extends Comparable<T>> T max(List<T> list)` need the `extends Comparable<T>` bound?",
    "choices": [
      "Without it, the compiler can't guarantee values of T support compareTo, so the comparison in the body would be rejected",
      "It makes the method run faster by skipping runtime type checks",
      "It forces the list to be stored on the heap instead of the stack",
      "It's optional styling - the method compiles fine with a bare <T>"
    ],
    "answer": 0,
    "explain": "A bound both restricts which types T may be and unlocks that type's methods inside the body. Comparable provides compareTo; without the bound, T could be a non-comparable type, so item.compareTo(biggest) would be rejected."
  },
  {
    "q": "You have `List<? extends Number> list`. Why does `list.add(42)` fail to compile?",
    "choices": [
      "The real element type is unknown - it might be List<Double> - so adding an Integer could corrupt it; ? extends is read-only",
      "42 is too large to be a valid Number",
      "Wildcards make a list completely read-only, including reads",
      "You must call list.allowAdd() first to enable writing"
    ],
    "answer": 0,
    "explain": "With ? extends Number, the compiler only knows the list holds SOME unknown subtype of Number. It can't let you add anything (you might put an Integer into a List<Double>). You can read elements as Number, but not write - Producer Extends."
  },
  {
    "q": "At runtime, what does `new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass()` evaluate to, and why?",
    "choices": [
      "true - type erasure removes the type parameter, so both are just ArrayList at runtime",
      "false - they are permanently different types, even at runtime",
      "It throws a ClassCastException because the types don't match",
      "true - but only because both lists happen to be empty"
    ],
    "answer": 0,
    "explain": "Generics are erased after compilation: List<String> and List<Integer> both become plain ArrayList in the bytecode. getClass() asks the runtime, which sees only ArrayList for both - so the comparison is true. This same erasure is why you can't do new T() or overload on erased types."
  }
]

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 3 questions

1. Why does `static <T extends Comparable<T>> T max(List<T> list)` need the `extends Comparable<T>` bound?

2. You have `List<? extends Number> list`. Why does `list.add(42)` fail to compile?

3. At runtime, what does `new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass()` evaluate to, and why?