Java Generic Classes and Methods
Generic classes and methods let you write one implementation that works with any type — while maintaining full type safety.
Generic Classes
Basic Generic Class
public class Container<T> {
private T value;
public Container(T value) {
this.value = value;
}
public T getValue() {
return value;
}
public void setValue(T value) {
this.value = value;
}
public boolean isEmpty() {
return value == null;
}
}
Container<String> name = new Container<>("Alice");
String s = name.getValue(); // no cast
Container<Integer> age = new Container<>(30);
int n = age.getValue(); // auto-unboxing
Multiple Type Parameters
public class Pair<K, V> {
private final K first;
private final V second;
public Pair(K first, V second) {
this.first = first;
this.second = second;
}
public K getFirst() { return first; }
public V getSecond() { return second; }
@Override
public String toString() {
return "(" + first + ", " + second + ")";
}
}
Pair<String, Integer> entry = new Pair<>("age", 30);
Pair<String, List<String>> grouped = new Pair<>("fruits", List.of("apple", "banana"));
Generic Class with Three+ Parameters
public class Triple<A, B, C> {
private final A first;
private final B second;
private final C third;
public Triple(A first, B second, C third) {
this.first = first;
this.second = second;
this.third = third;
}
public A getFirst() { return first; }
public B getSecond() { return second; }
public C getThird() { return third; }
}
Triple<String, Integer, Boolean> result = new Triple<>("OK", 200, true);
Generic Methods
A method can declare its own type parameters, independent of the class:
public class Util {
// <T> declares the type parameter before the return type
public static <T> T getFirst(List<T> list) {
if (list.isEmpty()) throw new NoSuchElementException();
return list.get(0);
}
public static <T> T getLast(List<T> list) {
if (list.isEmpty()) throw new NoSuchElementException();
return list.get(list.size() - 1);
}
}
String first = Util.getFirst(List.of("A", "B", "C")); // T = String
Integer num = Util.getFirst(List.of(1, 2, 3)); // T = Integer
Type Inference
The compiler usually infers the type from the arguments:
// Explicit (rarely needed)
String s = Util.<String>getFirst(List.of("A", "B"));
// Inferred (standard)
String s = Util.getFirst(List.of("A", "B"));
Static Generic Methods
Static methods cannot use the class’s type parameter — they must declare their own:
public class Box<T> {
private T content;
// ❌ COMPILE ERROR — T is the class's type parameter, not accessible in static context
// public static T createDefault() { return null; }
// ✅ CORRECT — declare a separate type parameter <U>
public static <U> Box<U> empty() {
return new Box<>();
}
public static <U> Box<U> of(U value) {
Box<U> box = new Box<>();
box.content = value;
return box;
}
}
Box<String> box = Box.of("Hello");
Box<Integer> empty = Box.empty();
Generic Constructors
Constructors can have their own type parameters (rare but valid):
public class MyClass {
private final Object data;
public <T> MyClass(T data) {
this.data = data;
}
}
Generic Interfaces
public interface Repository<T, ID> {
T findById(ID id);
List<T> findAll();
void save(T entity);
void delete(ID id);
}
public class UserRepository implements Repository<User, Long> {
@Override
public User findById(Long id) { ... }
@Override
public List<User> findAll() { ... }
@Override
public void save(User user) { ... }
@Override
public void delete(Long id) { ... }
}
Practical Example: Type-Safe Result Wrapper
public class Result<T> {
private final T value;
private final String error;
private final boolean success;
private Result(T value, String error, boolean success) {
this.value = value;
this.error = error;
this.success = success;
}
public static <T> Result<T> ok(T value) {
return new Result<>(value, null, true);
}
public static <T> Result<T> fail(String error) {
return new Result<>(null, error, false);
}
public boolean isSuccess() { return success; }
public T getValue() { return value; }
public String getError() { return error; }
}
// Usage
Result<User> result = userService.findUser(42);
if (result.isSuccess()) {
User user = result.getValue(); // type-safe, no cast
} else {
System.out.println(result.getError());
}
Practical Example: Generic Pair Utility
public class Pairs {
public static <A, B> Pair<A, B> of(A first, B second) {
return new Pair<>(first, second);
}
public static <A, B> Pair<B, A> swap(Pair<A, B> pair) {
return new Pair<>(pair.getSecond(), pair.getFirst());
}
public static <T> Pair<T, T> duplicate(T value) {
return new Pair<>(value, value);
}
}
Pair<String, Integer> p = Pairs.of("age", 30);
Pair<Integer, String> swapped = Pairs.swap(p); // (30, "age")
Pair<String, String> dup = Pairs.duplicate("hello"); // ("hello", "hello")
Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
| Using raw types | No type safety | Always specify <T> |
new T() |
Type erasure — can’t instantiate | Use factory method or Supplier<T> |
new T[10] |
Can’t create generic arrays | Use List<T> or @SuppressWarnings |
Static field of type T |
T is instance-scoped |
Use a static generic method instead |
| Overloading by generic type | Erasure makes them identical | Use different method names |
// ❌ After erasure, both are print(List) — compile error
void print(List<String> list) { }
void print(List<Integer> list) { }
Key Takeaways
- Generic classes use
<T>after the class name — one implementation, many types. - Generic methods use
<T>before the return type — independent of the class. - Static methods must declare their own type parameters.
- The compiler infers types from arguments — explicit
<T>is rarely needed. - Combine generics with interfaces for powerful abstractions (Repository, Result).
- You cannot create instances (
new T()) or arrays (new T[]) due to type erasure.
What’s Next?
Next: Bounded Type Parameters — restricting what types can be used with extends and multiple bounds.