Java Generic Classes and Methods

4 minute read

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

  1. Generic classes use <T> after the class name — one implementation, many types.
  2. Generic methods use <T> before the return type — independent of the class.
  3. Static methods must declare their own type parameters.
  4. The compiler infers types from arguments — explicit <T> is rarely needed.
  5. Combine generics with interfaces for powerful abstractions (Repository, Result).
  6. 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.

Updated: