Generic Classes
A generic class is a class declared with one or more type parameters: placeholders for types that the user of the class supplies later. The class is written once, and every use of it fills in concrete types: Box<String>, Box<Integer>, Repository<Student, Integer>. Inside the class, the type parameter is used like any other type, and the compiler checks every use against the type the caller chose. All of Java's collection classes are generic classes.
Declaring a Generic Class
Type parameters go in angle brackets right after the class name:
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;
}
}
T can appear as a field type, a parameter type, a return type, and a local variable type. It stands for "whatever type this particular Box was created with."
Box<String> name = new Box<>("Ada");
String s = name.get(); // returns String: no cast
Box<Integer> count = new Box<>(10);
count.set(count.get() + 1); // Integer arithmetic via unboxing
name.set(42); // compile error: int cannot be converted to String
Each parameterized type, Box<String> or Box<Integer>, behaves as if T had been replaced by that type throughout the class.
Without generics
The alternative is a class that stores Object:
public class ObjectBox {
private Object value;
public Object get() { return value; }
public void set(Object value) { this.value = value; }
}
ObjectBox b = new ObjectBox();
b.set("text");
Integer n = (Integer) b.get(); // compiles, then throws ClassCastException
It accepts anything, requires casts on every read, and fails at runtime. The generic version accepts only the declared type and fails at compile time instead.
Multiple Type Parameters
A class can have several type parameters, separated by commas:
public class Pair<A, B> {
private final A first;
private final B second;
public Pair(A first, B second) {
this.first = first;
this.second = second;
}
public A first() { return first; }
public B second() { return second; }
public Pair<B, A> swap() {
return new Pair<>(second, first);
}
@Override
public String toString() { return "(" + first + ", " + second + ")"; }
}
Pair<String, Integer> p = new Pair<>("Ada", 36);
Pair<Integer, String> q = p.swap();
System.out.println(q); // (36, Ada)
Map<K, V> and Map.Entry<K, V> are two-parameter generic types from the JDK. Generic records are written the same way: record Pair<A, B>(A first, B second) { }.
Bounded Type Parameters
By default, a type parameter can be any reference type, so inside the class you can only call methods that every object has (toString, equals, hashCode). A bound restricts the type parameter to a type and its subtypes, which makes that type's methods available:
public class NumberBox<T extends Number> {
private final T value;
public NumberBox(T value) { this.value = value; }
public double doubled() {
return value.doubleValue() * 2; // allowed: every Number has doubleValue()
}
}
new NumberBox<>(21).doubled(); // 42.0
new NumberBox<>(1.5).doubled(); // 3.0
new NumberBox<>("21"); // compile error: String is not a Number
extends is used for both classes and interfaces in a bound. A type parameter can have multiple bounds joined with &, with at most one class, listed first:
class Ranked<T extends Number & Comparable<T>> { ... }
Bounded types are covered in more depth in the Java Generics section.
Generic Classes and Inheritance
A generic class can extend or implement generic types, either fixing the type argument or passing its own parameter along:
// Fixes the type: a StringList is always a list of strings
class StringList extends ArrayList<String> { }
// Passes its parameter along: a LoggingList<E> is a List<E>
class LoggingList<E> extends ArrayList<E> {
@Override
public boolean add(E e) {
System.out.println("adding " + e);
return super.add(e);
}
}
A subclass can also add its own type parameters, as in class TimedBox<T, U> extends Box<T>.
Generic types are not covariant
Even though Integer is a subtype of Number, Box<Integer> is not a subtype of Box<Number>:
Box<Integer> ints = new Box<>(1);
Box<Number> nums = ints; // compile error: incompatible types
If it were allowed, code could call nums.set(3.14) and put a Double into a box that other code still treats as Box<Integer>. Wildcards (Box<? extends Number>) express "a box of some kind of Number" safely; they have their own lesson.
Generic Methods Inside Generic Classes
A generic class's methods can use the class's type parameter, and can also declare additional type parameters of their own:
public class Box<T> {
private final T value;
public Box(T value) { this.value = value; }
public T get() { return value; }
public <R> Box<R> map(Function<? super T, ? extends R> f) {
return new Box<>(f.apply(value));
}
}
Box<Integer> length = new Box<>("java").map(String::length);
System.out.println(length.get()); // 4
map introduces R, the type of the result, which can differ from T. This is the same pattern used by Optional.map and Stream.map. Generic methods have their own lesson.
What You Cannot Do with a Type Parameter
Generics are checked at compile time and then erased: at runtime, Box<String> and Box<Integer> are the same class.
Box<String> s = new Box<>("x");
Box<Integer> i = new Box<>(1);
System.out.println(s.getClass() == i.getClass()); // true
Because the actual type argument is not available at runtime, a few things are not allowed inside a generic class:
| Not allowed | Why | Common workaround |
|---|---|---|
new T() | The runtime does not know what T is | Accept a Supplier<T> or a Class<T> |
new T[10] | Same reason | Use a List<T>, or create an Object[] and cast carefully |
static T field | Static members are shared by all parameterizations | Make the member non-static |
obj instanceof T | T is erased | Pass a Class<T> and use type.isInstance(obj) |
Primitive type arguments (Box<int>) | Type arguments must be reference types | Use wrapper types (Box<Integer>) |
public class Factory<T> {
private final Supplier<T> creator;
public Factory(Supplier<T> creator) { this.creator = creator; }
public T create() { return creator.get(); }
}
Factory<StringBuilder> f = new Factory<>(StringBuilder::new);
StringBuilder sb = f.create();
Naming and Design Guidelines
- Use single uppercase letters for type parameters:
T,E,K,V,R. Longer names (TEntity) are occasionally used but are unusual in Java. - Make a class generic when it stores or processes values without depending on their exact type: containers, wrappers, caches, results, pages, events.
- Add a bound when the class needs to call methods of the type:
T extends Comparable<T>,T extends Entity<ID>. - Do not make a class generic just because you can. If it only ever works with one type, a plain class is clearer.
Practical Example: A Generic In-Memory Repository
Many applications need the same operations for different kinds of records: save, find by ID, search, delete. A generic repository implements them once for any entity type and any ID type.
import java.util.*;
import java.util.function.Predicate;
public class Repo {
interface Entity<ID> { ID id(); }
static class InMemoryRepository<T extends Entity<ID>, ID> {
private final Map<ID, T> store = new LinkedHashMap<>();
T save(T entity) {
store.put(entity.id(), entity);
return entity;
}
Optional<T> findById(ID id) {
return Optional.ofNullable(store.get(id));
}
List<T> findWhere(Predicate<T> condition) {
List<T> result = new ArrayList<>();
for (T e : store.values()) {
if (condition.test(e)) result.add(e);
}
return result;
}
boolean delete(ID id) { return store.remove(id) != null; }
int count() { return store.size(); }
}
record Student(Integer id, String name, int year) implements Entity<Integer> { }
record Course(String id, String title, int hours) implements Entity<String> { }
public static void main(String[] args) {
InMemoryRepository<Student, Integer> students = new InMemoryRepository<>();
students.save(new Student(1, "Ada", 2));
students.save(new Student(2, "Linus", 3));
students.save(new Student(3, "Grace", 2));
InMemoryRepository<Course, String> courses = new InMemoryRepository<>();
courses.save(new Course("JAVA101", "Java Basics", 20));
courses.save(new Course("DOCK210", "Docker", 12));
System.out.println(students.findById(2).map(Student::name).orElse("none"));
System.out.println(students.findWhere(s -> s.year() == 2));
System.out.println(courses.findById("PY999").isPresent());
System.out.println(courses.findWhere(c -> c.hours() > 15));
students.delete(1);
System.out.println(students.count() + " students, " + courses.count() + " courses");
}
}
Output:
Linus
[Student[id=1, name=Ada, year=2], Student[id=3, name=Grace, year=2]]
false
[Course[id=JAVA101, title=Java Basics, hours=20]]
2 students, 2 courses
InMemoryRepository has two type parameters: T, the entity type, and ID, the key type. The bound T extends Entity<ID> guarantees that every entity has an id() method returning the right kind of key, which is what lets save call entity.id(). One class serves a repository of Students keyed by Integer and a repository of Courses keyed by String. The compiler keeps them apart: saving a Course into the student repository, or looking up a student by a String ID, does not compile. findById returns Optional<T>, so callers get a correctly typed result (Optional<Student>) without casts. This is the same shape as the repository interfaces in Spring Data JPA, covered in the frameworks section.