Java Generics
Generics let you write classes, interfaces, and methods that operate on a type parameter instead of a fixed, hard-coded type. Instead of writing a separate StringBox, IntegerBox, and DoubleBox, you write one Box<T> and let the caller decide what T is. The compiler then enforces type safety at compile time, eliminating the runtime ClassCastException surprises that plagued pre-Java-5 code, and it removes the need for manual casting when you retrieve values from collections.
Generics are everywhere in modern Java — every collection in java.util is generic, the Stream API is built on generic functional interfaces, and most well-designed libraries expose generic APIs. Understanding generics thoroughly, including their quirks, is essential to reading and writing idiomatic Java.
Overview: How Generics Work
A generic type is declared with one or more type parameters in angle brackets, conventionally named with single uppercase letters: T (Type), E (Element), K/V (Key/Value), R (Result). When you write Box<String>, String is called a type argument supplied for the type parameter T.
Crucially, Java generics are implemented using type erasure. This means generic type information exists only at compile time; the compiler uses it to check your code, then strips it out, replacing type parameters with their bound (or Object if unbounded) in the compiled .class file. At runtime, a Box<String> and a Box<Integer> are literally the exact same class — Box — there is only one class file. The compiler inserts the necessary casts automatically wherever you retrieve a value, so your code behaves as if the type information were still there, but a running JVM has no idea what T was at any particular call site.
This design choice was made for backward compatibility: generics were added in Java 5, and erasure allowed generic code to interoperate with pre-generics ("raw type") code and bytecode. It also explains several restrictions you’ll hit later, such as not being able to create an array of a generic type, or use instanceof with a type parameter.
Syntax
class ClassName<T> {
private T field;
public void set(T value) { field = value; }
public T get() { return field; }
}
// Multiple type parameters
class Pair<K, V> { K key; V value; }
// Bounded type parameter
class NumericBox<T extends Number> { T value; }
// Generic method
static <T> T firstElement(List<T> list) { return list.get(0); }
// Wildcards
void printAll(List<?> list) { }
void readFrom(List<? extends Number> list) { }
void writeTo(List<? super Integer> list) { }
T,E,K,V— type parameter names, placeholders filled in when the type is used.<T extends Number>— a bounded type parameter;Tmust beNumberor a subclass. (extendsis used even for interfaces here.)<T extends Comparable&Serializable>— multiple bounds, joined with&; at most one bound may be a class, and it must come first.<?>— an unbounded wildcard, meaning "some unknown type."<? extends Number>— an upper-bounded wildcard: any type that isNumberor a subtype. Good for reading data out (a "producer").<? super Integer>— a lower-bounded wildcard:Integeror any supertype. Good for writing data in (a "consumer").
| Form | Meaning | Typical use |
|---|---|---|
List<T> |
Exact, known type parameter | Class/method definitions |
List<?> |
Unknown type, read-only as Object |
Method just needs to inspect the list generically |
List<? extends T> |
T or any subtype (producer) | Reading elements out safely |
List<? super T> |
T or any supertype (consumer) | Adding elements in safely |
Examples
Example 1: A Generic Box Class
public class Main {
static class Box<T> {
private T content;
public void set(T content) { this.content = content; }
public T get() { return content; }
}
public static void main(String[] args) {
Box<String> stringBox = new Box<>();
stringBox.set("Hello Generics");
System.out.println("String box contains: " + stringBox.get());
Box<Integer> intBox = new Box<>();
intBox.set(42);
System.out.println("Integer box contains: " + intBox.get());
}
}
Output:
String box contains: Hello Generics
Integer box contains: 42
The same Box class works for both a String and an Integer with no casting required, and no way to accidentally call intBox.set("oops") — the compiler would reject it.
Example 2: A Bounded Generic Method
public class Main {
static <T extends Comparable<T>> T max(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}
public static void main(String[] args) {
System.out.println("Max of 10 and 25: " + max(10, 25));
System.out.println("Max of apple and banana: " + max("apple", "banana"));
System.out.println("Max of 3.5 and 2.1: " + max(3.5, 2.1));
}
}
Output:
Max of 10 and 25: 25
Max of apple and banana: banana
Max of 3.5 and 2.1: 3.5
The bound T extends Comparable<T> tells the compiler that whatever type is used must implement compareTo, so the method body can call it safely. This one method works for Integer, String, Double, or any custom class implementing Comparable.
Example 3: Wildcards for Flexible APIs
import java.util.Arrays;
import java.util.List;
public class Main {
static double sumOfList(List<? extends Number> list) {
double sum = 0.0;
for (Number n : list) {
sum += n.doubleValue();
}
return sum;
}
public static void main(String[] args) {
List<Integer> integers = Arrays.asList(1, 2, 3, 4);
List<Double> doubles = Arrays.asList(1.5, 2.5, 3.0);
System.out.println("Sum of integers: " + sumOfList(integers));
System.out.println("Sum of doubles: " + sumOfList(doubles));
}
}
Output:
Sum of integers: 10.0
Sum of doubles: 7.0
Without the wildcard, sumOfList would have to be declared as List<Number>, and neither List<Integer> nor List<Double> would be accepted, because generics are invariant — List<Integer> is not a subtype of List<Number> even though Integer is a subtype of Number. The ? extends Number wildcard is what makes the method accept a list of any Number subtype.
Under the Hood: Type Erasure in Action
When javac compiles a generic class, it performs these steps:
- It checks every use of the type parameter against its declared bound (
Objectif none was given), rejecting any operation that isn’t valid for that bound. - It replaces the type parameter with its erasure (the bound, or
Object) throughout the compiled class.Box<T>becomes a class whose field is literally typedObjectin the bytecode. - It inserts synthetic casts at every point where a value is read back out with the specific type, so
String s = box.get()compiles to "get theObject, then cast it toString" in bytecode. - For overriding a generic method with a more specific type, the compiler generates a bridge method so that both the generic and erased signatures exist and dispatch correctly at runtime.
Because the type parameter disappears at runtime, several things are impossible with generics: you cannot write new T(), new T[10], or obj instanceof List<String> (only obj instanceof List<?> is legal), and a class cannot have two overloaded methods that differ only by generic type argument (like void set(List<String> s) and void set(List<Integer> s) in the same class) because after erasure both have the identical signature void set(List).
Common Mistakes
Mistake 1: Trying to Create an Array of a Generic Type
Because of type erasure, the JVM cannot verify at runtime what array element type was intended, so creating a generic array directly is a compile-time error:
class Stack<T> {
private T[] elements;
private int size = 0;
public Stack(int capacity) {
elements = new T[capacity]; // compile error: generic array creation
}
public void push(T item) {
elements[size++] = item;
}
}
The fix is to back the structure with an Object[] internally and cast when reading a value out (suppressing the resulting unchecked warning, since you control the invariant yourself):
public class Main {
static class Stack<T> {
private Object[] elements;
private int size = 0;
public Stack(int capacity) {
elements = new Object[capacity];
}
public void push(T item) {
elements[size++] = item;
}
@SuppressWarnings("unchecked")
public T pop() {
T item = (T) elements[--size];
elements[size] = null;
return item;
}
}
public static void main(String[] args) {
Stack<String> stack = new Stack<>(5);
stack.push("first");
stack.push("second");
System.out.println("Popped: " + stack.pop());
System.out.println("Popped: " + stack.pop());
}
}
Output:
Popped: second
Popped: first
Mistake 2: Mixing Raw Types With Generics
Using a raw type (a generic class without its type argument, e.g. plain List) disables compile-time checking for that variable and just produces an "unchecked" warning instead of an error, so bad data can sneak in and blow up later, far from where the mistake was made:
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List rawList = new ArrayList();
rawList.add("a string");
rawList.add(42);
List<String> strings = rawList;
for (String s : strings) {
System.out.println(s.toUpperCase());
}
}
}
Output:
A STRING
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String
at Main.main(Main.java:10)
The first element prints fine, but the second element (an Integer) blows up when the compiler-inserted cast to String runs, even though nothing in the visible code performs an explicit cast. Avoiding raw types entirely and keeping everything generically typed end-to-end catches this at compile time instead:
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> strings = new ArrayList<>();
strings.add("a string");
for (String s : strings) {
System.out.println(s.toUpperCase());
}
}
}
Output:
A STRING
Best Practices
- Never use raw types (bare
List,Box) in new code — always supply a type argument, even if it’s just<Object>or<?>. - Follow the PECS mnemonic: "Producer
extends, Consumersuper" when choosing wildcards for method parameters. - Prefer generic methods over casting; let the compiler infer type arguments from the call site whenever possible.
- Use the diamond operator
<>(e.g.new ArrayList<>()) instead of repeating the type argument on both sides of an assignment. - Bound type parameters (
<T extends Comparable<T>>) when your generic code needs to call methods beyond whatObjectprovides. - Don’t overload methods based only on generic type arguments — erasure makes the signatures collide.
- Reach for
@SuppressWarnings("unchecked")sparingly, only on the smallest possible scope, and only when you’ve manually verified the cast is safe.
Practice Exercises
- Write a generic class
Pair<K, V>with a constructor taking a key and a value, and getter methods for each. Create aPair<String, Integer>mapping a name to an age and print both fields. - Write a generic method
<T> void printArray(T[] array)that prints every element of any array on its own line, then call it with aString[]and anInteger[]. - Write a method
static void addNumbers(List<? super Integer> list)that adds the integers 1, 2, and 3 into the list. Call it once with aList<Integer>and once with aList<Number>, and print each list afterward to confirm both compile and work.
Summary
- Generics let a single class or method work across many types while the compiler enforces type safety, removing the need for manual casts.
- Java implements generics via type erasure: type parameters exist only at compile time and are erased to
Object(or their bound) in the compiled bytecode. - Bounded type parameters (
<T extends X>) let generic code call methods declared on the bound. - Wildcards (
?,? extends X,? super X) make APIs flexible when the exact type parameter doesn’t matter, following the PECS rule. - Because of erasure, you cannot create generic arrays, use
instanceofwith a parameterized type, or overload purely on type arguments. - Avoid raw types — they silently disable the type checking generics exist to provide, deferring errors to runtime.
