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
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
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.
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
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);
}
}
pick("alpha", "beta")infersT = Stringand returns the first argument,alpha.pick(10, 20)infersT = Integer(the ints are boxed) and returns10.
On a method, what does the leading <T> in <T> T firstOf(T a, T b) do?
The <T> before the return type is what introduces the type parameter. Without it the compiler would treat the T in the return type and parameters as a missing class. Once declared, T can be used as the return type and in the parameter list, and the caller never needs to spell it out.
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);
}
}
p.firstis theString"age",p.secondis theInteger42.+joins them into one line with a colon and a space.
Why does List<int> fail to compile?
Generics only work with reference types. Under type erasure the element type becomes Object at runtime, and a primitive can never sit in an Object slot, so the compiler rejects List<int>. The wrapper class Integer is the reference-type stand-in, and auto-boxing makes adding plain ints painless.
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);
}
}
identity("echo")infersT = Stringand returns the stringecho.identity(7)infersT = Integer, returns7, then unboxes so7 + 1is8.
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 plainintvalues seamlessly.
Next you will compare List, Set, and Map head to head, and learn exactly when each is the right container for the job.