Generics Basics
Generics let a class, interface, or method work with any type while keeping full compile-time type checking. Instead of writing one list for strings, another for integers, and another for products, you write List<E> once, and users choose the type: List<String>, List<Integer>, List<Product>. The compiler then guarantees that only the right kind of element goes in and that every element coming out already has the right type. Every collection in the previous lessons is generic.
The Problem Generics Solve
Before Java 5, collections held plain Object references:
List names = new ArrayList(); // a "raw" list: holds any Object
names.add("Ada");
names.add(42); // nothing stops this
String first = (String) names.get(0); // cast required
String second = (String) names.get(1); // ClassCastException at runtime
Two problems are visible here:
- No type safety. The compiler cannot stop an
Integerfrom entering a list meant for names. The mistake only surfaces later, at runtime, far from where it was made. - Casts everywhere. Every read needs a cast, which is noisy and is exactly where the failure happens.
With generics:
List<String> names = new ArrayList<>();
names.add("Ada");
names.add(42); // compile error: int cannot be converted to String
String first = names.get(0); // no cast needed
The error moves from runtime to compile time, and the casts disappear. That is the central benefit of generics: errors are caught by the compiler instead of by your users.
Reading Generic Types
List<String> // a List whose elements are Strings
Map<String, Integer> // a Map from String keys to Integer values
Map<String, List<Order>> // a Map from String to lists of Orders
Optional<LocalDate> // an Optional that may hold a LocalDate
Comparator<Product> // a Comparator that compares Products
| Term | Example | Meaning |
|---|---|---|
| Generic type | List<E> | A type declared with type parameters |
| Type parameter | E in List<E> | A placeholder name used inside the declaration |
| Type argument | String in List<String> | The actual type supplied by the user |
| Parameterized type | List<String> | A generic type with its arguments filled in |
By convention, type parameters are single uppercase letters:
| Letter | Usual meaning |
|---|---|
T | Type |
E | Element (collections) |
K, V | Key, Value (maps) |
R | Return type (functions) |
S, U | Additional types |
The Diamond Operator
The type arguments on the right side of an assignment can usually be inferred from the left:
Map<String, List<Integer>> scores = new HashMap<String, List<Integer>>(); // verbose
Map<String, List<Integer>> scores = new HashMap<>(); // diamond
var scores = new HashMap<String, List<Integer>>(); // var: type on the right
<> (the "diamond") tells the compiler to infer the arguments. With var, the type arguments must appear on the right instead, because there is nothing on the left to infer from.
Writing a Generic Class
Declare type parameters in angle brackets after the class name, then use them like ordinary types inside the class:
public class Box<T> {
private T value;
public Box(T value) { this.value = value; }
public T get() { return value; }
public void set(T value) { this.value = value; }
public boolean isEmpty() { return value == null; }
}
Box<String> label = new Box<>("Fragile");
String text = label.get(); // T is String here: no cast
Box<Integer> count = new Box<>(3);
int n = count.get(); // T is Integer: unboxed automatically
label.set(99); // compile error: int cannot be converted to String
One class definition serves every type. A record can be generic too:
public record Pair<A, B>(A first, B second) { }
Pair<String, Integer> p = new Pair<>("Ada", 36);
String name = p.first();
int age = p.second();
Generic classes have their own lesson.
Writing a Generic Method
A method can declare its own type parameters, placed before the return type:
public static <T> T firstOrDefault(List<T> list, T fallback) {
return list.isEmpty() ? fallback : list.get(0);
}
String s = firstOrDefault(List.of("x", "y"), "none"); // T inferred as String
Integer i = firstOrDefault(List.<Integer>of(), 0); // T inferred as Integer
The compiler infers T from the arguments at each call. The method works for any type and returns exactly the type that was passed in. Generic methods have their own lesson.
Bounded Type Parameters (Preview)
Sometimes a generic method needs to call methods on T. A bound restricts which types are allowed:
public static <T extends Comparable<T>> T maxOf(List<T> items) {
T best = items.get(0);
for (T item : items) {
if (item.compareTo(best) > 0) best = item;
}
return best;
}
T extends Comparable<T> means "any type that can be compared with itself." Inside the method, item.compareTo(best) is therefore allowed. Calling maxOf with a list of a type that is not Comparable is a compile error. Bounded types and wildcards (? extends, ? super) are covered in later lessons.
Generics Work Only with Reference Types
Type arguments must be classes or interfaces, not primitives:
List<int> numbers; // compile error
List<Integer> numbers; // correct: autoboxing converts between int and Integer
For large amounts of numeric data, boxing has a cost: each Integer is a separate object. Primitive arrays (int[]) or primitive streams (IntStream) avoid it.
Type Erasure (In Brief)
Generics are a compile-time feature. After checking types, the compiler erases the type arguments: at runtime a List<String> and a List<Integer> are both just List. This design kept new generic code compatible with older libraries, and it has a few visible consequences:
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true: same runtime class
// Not allowed, because T is unknown at runtime:
// new T() cannot create an instance of a type parameter
// new T[10] cannot create a generic array
// x instanceof List<String> cannot test a parameterized type (except with an unbounded ?)
Type erasure has its own lesson. For everyday code, the rule is simple: let the compiler check the types, and avoid working around it.
Raw Types: Do Not Use Them
Using a generic type without type arguments creates a raw type:
List names = new ArrayList(); // raw: warning "unchecked call"
Raw types exist only for compatibility with pre-2004 code. They switch off type checking for that variable, bringing back the runtime ClassCastExceptions that generics were designed to prevent. Always supply type arguments (or <>), and treat "unchecked" compiler warnings as real problems.
Practical Example: A Generic Page of Results
APIs often return results one page at a time. The same paging logic applies whether the items are courses, users, or orders, so it is a natural candidate for generics.
import java.util.*;
import java.util.function.Function;
public class Gen {
static final class Page<T> {
private final List<T> items;
private final int number, totalPages;
Page(List<T> items, int number, int totalPages) {
this.items = List.copyOf(items);
this.number = number;
this.totalPages = totalPages;
}
List<T> items() { return items; }
boolean hasNext() { return number < totalPages; }
<R> Page<R> map(Function<T, R> f) {
List<R> out = new ArrayList<>();
for (T item : items) out.add(f.apply(item));
return new Page<>(out, number, totalPages);
}
@Override
public String toString() { return "Page " + number + "/" + totalPages + " " + items; }
}
static <T> Page<T> paginate(List<T> all, int pageNumber, int pageSize) {
int total = Math.max(1, (all.size() + pageSize - 1) / pageSize);
int from = Math.min((pageNumber - 1) * pageSize, all.size());
int to = Math.min(from + pageSize, all.size());
return new Page<>(all.subList(from, to), pageNumber, total);
}
static <T extends Comparable<T>> T maxOf(List<T> items) {
T best = items.get(0);
for (T item : items) {
if (item.compareTo(best) > 0) best = item;
}
return best;
}
record Course(String code, int hours) { }
public static void main(String[] args) {
List<Course> catalog = List.of(
new Course("JAVA101", 20), new Course("DOCK210", 12), new Course("DJ201", 30),
new Course("CPP301", 40), new Course("BASH110", 8));
Page<Course> p1 = paginate(catalog, 1, 2);
System.out.println(p1);
Page<String> codes = p1.map(Course::code);
System.out.println(codes + ", has next: " + codes.hasNext());
Page<Integer> hours = paginate(catalog, 3, 2).map(Course::hours);
System.out.println(hours + ", has next: " + hours.hasNext());
System.out.println("Last code alphabetically: " + maxOf(List.of("JAVA101", "DOCK210", "DJ201")));
System.out.println("Most hours: " + maxOf(List.of(20, 12, 30, 40, 8)));
}
}
Output:
Page 1/3 [Course[code=JAVA101, hours=20], Course[code=DOCK210, hours=12]]
Page 1/3 [JAVA101, DOCK210], has next: true
Page 3/3 [8], has next: false
Last code alphabetically: JAVA101
Most hours: 40
Page<T> is a generic class: one definition holds a page of courses, strings, or integers, and the compiler knows the element type in each case. paginate is a generic method whose T is inferred from the list passed in, so paginate(catalog, 1, 2) returns a Page<Course>. The map method introduces a second type parameter, R, letting a Page<Course> become a Page<String> or Page<Integer> while keeping the page number and total. maxOf uses a bounded type parameter so it can call compareTo, and it works for both strings and integers because both are Comparable. No casts appear anywhere, and passing the wrong type to any of these methods would be caught at compile time.