Module 7 · Collections & Generics ⏱ 19 min

Generics Fundamentals

By the end of this lesson you will be able to:
  • Declare generic classes and methods and read what the angle brackets mean
  • Explain how generics move type errors from runtime into the build
  • Predict the consequences of type erasure for primitives and runtime checks

Before generics, a List held Object — literally anything. You could drop a String, an Integer, and a Thread into the same list, and the compiler would not raise an eyebrow. The trouble came when you took things back out. To use an element you had to cast it: String s = (String) list.get(0). Cast the wrong way — say the element was actually an Integer — and the program blew up at runtime with a ClassCastException, often on a line far from the one that caused it.

That is the problem generics were built to solve. By declaring a List<String>, you tell the compiler up front that this list holds only strings. The compiler then refuses a wrong-type add before the program ever runs. A whole family of crash — the bad cast — moves out of production and into a build error you see the moment you save the file.

flowchart TD
  RAW["raw List, holds Object"] --> CAST["read needs a cast"]
  CAST -.->|"wrong cast"| CRASH["ClassCastException at runtime"]
  GEN["generic List, compile-checked"] -->|"add wrong type"| REJ["rejected before it runs"]
  GEN -->|"add correct type"| OK["accepted, no cast needed"]
  style GEN fill:#3776ab,color:#fff
  style CRASH fill:#b45309,color:#fff
  style REJ fill:#b45309,color:#fff
A raw list forces a cast that can fail at runtime; a generic list is checked at compile time, so the bad cast never exists to fail.

What the angle brackets mean

Read List<String> as a list of strings. The type inside the angle brackets is a type parameter — a placeholder filled in with a real type when the container is used. The pair together, List<String>, is called a parameterized type.

The same idea applies to your own types. When you write class Box<T>, the T is a placeholder for some type, chosen by whoever uses Box. Box<String> is a box that holds strings; Box<Integer> holds integers. The class is written once but works for any type, and that reuse without sacrificing type safety is the whole point of the feature.

You will meet single-letter names constantly: T for a type, E for an element, and the pair K, V for key and value. These are naming convention, not language keywords, but following them keeps code readable.

Where the brackets go

A type parameter lives in one of two places. On a class, it is declared right after the class name: class Box<T> introduces T, and every field and method inside may then use it. On a method, it is declared just before the return type: <T> T firstOf(T a, T b). That leading <T> is what tells the compiler this method is generic; omit it and the T in the return type looks like a real class that does not exist.

When you call a generic method you rarely spell the type out — the compiler infers it from the arguments. firstOf("x", "y") is understood as T = String with no help from you. Inference is why generics feel light in practice: you get compile-time checking without naming the type at every call site.

flowchart TD
  A["on a class: brackets after the name"] --> U1["type usable across the whole class"]
  B["on a method: brackets before the return type"] --> U2["type usable only inside that method"]
  style A fill:#3776ab,color:#fff
  style B fill:#c2410c,color:#fff
A type parameter is declared in two places: after a class name (usable everywhere inside) or before a method's return type (usable only inside that method).
Main.java — a generic method. The leading <T> declares the parameter; the compiler infers T from each call. Real, compiling Java.
public class Main {
    // <T> declares a type parameter; T is also the return type
    static <T> T pick(T a, T b) {
        return a;
    }

    public static void main(String[] args) {
        String word = pick("alpha", "beta");   // T inferred as String
        Integer num = pick(10, 20);            // T inferred as Integer
        System.out.println(word);
        System.out.println(num);
    }
}

A generic class

A class can take more than one type parameter. A Pair that bundles two values of different types needs two: class Pair<A, B>. Each is then available throughout the class — A first and B second become the types of its fields, and getFirst() returns an A with no cast anywhere. The moment you write new Pair<String, Integer>("age", 42), the compiler pins A to String and B to Integer, and checks every later use of that object against those pinned types.

Notice the empty diamond on the right side: new Pair<>(...). Writing the type twice would be redundant, so the <> says use whatever type I declared on the left. That shorthand is called the diamond operator, and it is the normal way to construct a generic object.

Main.java — a generic class with two type parameters. Real, compiling Java.
public class Main {
    static class Pair<A, B> {
        A first;
        B second;

        Pair(A first, B second) {
            this.first = first;
            this.second = second;
        }
    }

    public static void main(String[] args) {
        Pair<String, Integer> p = new Pair<>("age", 42);
        System.out.println(p.first + ": " + p.second);
    }
}

The trap: type erasure

Generics are a compile-time feature. Once the compiler has checked your types, it throws the parameter information away — a process called type erasure. At runtime a List<String> and a List<Integer> are both simply a List; the <String> and <Integer> are gone. That is why you cannot ask at runtime whether a list is a list of strings — the answer is always it is a list.

Erasure explains two puzzles beginners hit. First, primitives cannot be type parameters: there is no List<int>, only List<Integer>, because the erased type must be a reference type. Second, you cannot build a generic array with new T[10] — at runtime there is no T to make an array of. Reach for an ArrayList instead, which is almost always what you wanted anyway.

flowchart LR
  LS["List of String"] -.->|"erases to"| RAW["raw List at runtime"]
  LI["List of Integer"] -.->|"erases to"| RAW
  style RAW fill:#c2410c,color:#fff
Type erasure: the compiler checks the angle brackets, then removes them. At runtime a list of strings and a list of integers are the same raw List.
Exercise

What does this program print? The method is generic — the compiler infers T from each call. Type the exact output (two lines).

public class Main {
    static <T> T pick(T a, T b) {
        return a;
    }

    public static void main(String[] args) {
        String word = pick("alpha", "beta");
        Integer num = pick(10, 20);
        System.out.println(word);
        System.out.println(num);
    }
}
Exercise

On a method, what does the leading <T> in <T> T firstOf(T a, T b) do?

Exercise

A generic class. Pair holds two values of two types. Predict the exact output (one line).

public class Main {
    static class Pair<A, B> {
        A first;
        B second;

        Pair(A first, B second) {
            this.first = first;
            this.second = second;
        }
    }

    public static void main(String[] args) {
        Pair<String, Integer> p = new Pair<>("age", 42);
        System.out.println(p.first + ": " + p.second);
    }
}
Exercise

Why does List<int> fail to compile?

Exercise

Boxing in action. identity returns whatever it is given. Predict the exact output (two lines).

public class Main {
    static <T> T identity(T value) {
        return value;
    }

    public static void main(String[] args) {
        System.out.println(identity("echo"));
        System.out.println(identity(7) + 1);
    }
}

Recap

  • Generics let you declare what a type holds (List<String>) so the compiler catches wrong-type use at build time, not at runtime.
  • A type parameter (T, E, K, V) is a placeholder; on a class it follows the name, on a method it precedes the return type.
  • The compiler infers the type at call sites, and the diamond <> saves you restating it at construction.
  • Type erasure removes the parameter at runtime — primitives cannot be parameters, and generic arrays cannot be created.
  • Auto-boxing lets List<Integer> accept plain int values seamlessly.

Next you will compare List, Set, and Map head to head, and learn exactly when each is the right container for the job.

Checkpoint quiz

What is the main benefit of writing List<String> instead of a raw List?

At runtime, how do List<String> and List<Integer> compare?

Go deeper — technical resources