Generic Classes
Declare classes and records with type parameters so one Page, Cache or response wrapper works for any element type without casts.
class Name<T> { private T value; T get() { return value; } }
record Pair<A, B>(A first, B second) {}
Name<String> n = new Name<>();A generic class takes one or more type parameters in angle brackets after its name. Inside the class, the parameter stands in for a real type; the caller picks that type when declaring a variable. List<Order>, Map<String, User> and Optional<Invoice> are all generic classes or interfaces you already use.
Why they exist
Before Java 5, containers held Object. You cast on the way out, and a wrong cast failed at runtime, often far from where the bad value went in. With a type parameter the compiler knows what the container holds, rejects the wrong type at the call site and inserts the cast for you. The bug moves from a production stack trace to a compile error.
record Page<T>(List<T> items, int page, int size, long total) {}
Page<Product> products = new Page<>(rows, 1, 20, 143);
Product first = products.items().get(0); // no castThe empty <> on the right is the diamond: the compiler copies the type arguments from the left-hand side. Records, interfaces and classes can all be generic.
Naming and scope
By convention a type parameter is one capital letter: T for a type, E for an element, K and V for keys and values, R for a result. ID is common in repositories. The parameter belongs to each instance, so a static field or method cannot use the class's T: there is only one static member shared by Page<User> and Page<String>.
Mistakes people make
- Raw types. Writing
PageorListwithout type arguments switches the checks off. By default the compiler prints at most a one-line note about unchecked operations, which is easy to ignore. Raw types exist for code written before Java 5; new code should never use them. - Primitive type arguments.
List<int>does not compile. Use the wrapper (List<Integer>) and let autoboxing convert. Compare the boxed values withequals, not==. - Making everything generic. A type parameter earns its place when the same code really serves several types. A
UserService<T>that only ever handles users is noise.
The back-end angle
Generic classes shine in the plumbing of a service: a page of results, an API envelope with data and errors, a small cache, a result type that holds either a value or a failure. Write it once, and every endpoint gets a typed version. JSON libraries such as Jackson read the type arguments from your field and method declarations, so a well-typed Page<ProductDto> return type also documents the response shape.
Example
When to use it
- Return a Page<T> record from every list endpoint so paging fields are named and typed the same way across the API.
- Wrap responses in an ApiResponse<T> envelope that carries typed data plus a list of error messages.
- Build a small Cache<K, V> in front of a slow lookup, such as exchange rates keyed by currency code.
More examples
Casting from Object versus a type parameter
ObjectBox accepts anything, so the mistake only shows up as a ClassCastException when the value is read. Box<String> rejects the wrong type where it is written, and get() needs no cast.
class ObjectBox {
private Object value;
void set(Object value) { this.value = value; }
Object get() { return value; }
}
class Box<T> {
private T value;
void set(T value) { this.value = value; }
T get() { return value; }
}
void main() {
ObjectBox old = new ObjectBox();
old.set(42);
try {
String id = (String) old.get();
IO.println(id);
} catch (ClassCastException e) {
IO.println("runtime failure: wrong cast");
}
Box<String> box = new Box<>();
box.set("order-17");
// box.set(42); // does not compile: int cannot be converted to String
String id = box.get();
IO.println("checked at compile time: " + id);
}
// Output:
// runtime failure: wrong cast
// checked at compile time: order-17Two type parameters: a small loading cache
K and V are chosen per cache, so the same class can hold users by Long id or settings by String key. The second lookup for id 1 is served from the map without calling the loader.
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Function;
class Cache<K, V> {
private final Map<K, V> entries = new LinkedHashMap<>();
private int loads;
V get(K key, Function<K, V> loader) {
return entries.computeIfAbsent(key, k -> {
loads++;
return loader.apply(k);
});
}
int loads() { return loads; }
}
void main() {
Cache<Long, String> users = new Cache<>();
IO.println(users.get(1L, id -> "user-" + id));
IO.println(users.get(1L, id -> "user-" + id));
IO.println(users.get(2L, id -> "user-" + id));
IO.println("loads: " + users.loads());
}
// Output:
// user-1
// user-1
// user-2
// loads: 2Primitives go in as wrappers
Type arguments must be reference types, so 1000 is boxed into an Integer. == compares two different Integer objects and is false, while equals and the record's generated equals compare the values.
record Reading<T>(T value) {}
void main() {
// Reading<int> r; // does not compile: primitive type arguments are not allowed
Reading<Integer> a = new Reading<>(1000);
Reading<Integer> b = new Reading<>(1000);
int sum = a.value() + b.value(); // unboxed automatically
IO.println(sum);
IO.println(a.value() == b.value());
IO.println(a.value().equals(b.value()));
IO.println(a.equals(b));
}
// Output:
// 2000
// false
// true
// true
Discussion