Generic Classes

Declare classes and records with type parameters so one Page, Cache or response wrapper works for any element type without casts.

Syntaxclass 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 cast

The 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 Page or List without 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 with equals, 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · java
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-17

Two 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.

Example · java
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: 2

Primitives 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.

Example · java
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

  • Be the first to comment on this lesson.