Skip to content
C Codeloom
Java

Java Generics: Type Parameters Done Right

Write Java generics that compile and read well. Type parameters, bounded wildcards, PECS, type erasure, and how to avoid raw types in your own code.

·5 min read · By Yash Kesharwani
Intermediate 9 min read

What you'll learn

  • Declare generic classes and methods
  • Use bounded type parameters and multiple bounds
  • Apply PECS to choose extends vs super wildcards
  • Understand type erasure and its consequences
  • Avoid raw types and unchecked warnings in your own code

Prerequisites

Generics let you write code that works for many types while keeping full type safety at compile time. The syntax is famously fiddly, but the rules behind it are simple once you see them as a contract about who reads from and who writes to a container.

In this article I will write generic type parameters inside code blocks, since outside backticks they look like HTML.

A generic class

A type parameter goes between angle brackets after the class name. By convention, single letters: T for type, E for element, K and V for key and value.

public class Box<T> {
    private final T value;

    public Box(T value) {
        this.value = value;
    }

    public T get() {
        return value;
    }
}

Box<String> b = new Box<>("hello");
String s = b.get();   // no cast required

The diamond <> on the right lets the compiler infer the type argument.

Generic methods

A method can declare its own type parameter before the return type:

public static <T> T firstOrDefault(List<T> list, T fallback) {
    return list.isEmpty() ? fallback : list.get(0);
}

String name = firstOrDefault(List.of(), "anonymous");

The compiler infers T from the arguments. You rarely write it explicitly.

Bounded type parameters

Constrain the type with extends (works for classes and interfaces).

public static <T extends Comparable<T>> T max(List<T> items) {
    T best = items.get(0);
    for (T item : items) {
        if (item.compareTo(best) > 0) best = item;
    }
    return best;
}

Multiple bounds use &:

public static <T extends Number & Comparable<T>> T smaller(T a, T b) {
    return a.compareTo(b) <= 0 ? a : b;
}

The first bound must be a class if any; the rest must be interfaces.

Wildcards

Wildcards (?) describe an unknown type at the use site. The two bounded forms are the ones you will read in real code:

  • ? extends Foo is an unknown type that is Foo or a subtype.
  • ? super Foo is an unknown type that is Foo or a supertype.
// Reads Numbers; accepts a List of Integer, Double, etc.
double sum(List<? extends Number> nums) {
    double total = 0;
    for (Number n : nums) total += n.doubleValue();
    return total;
}

// Writes Integers; accepts a List of Integer, Number, or Object.
void addInts(List<? super Integer> out) {
    out.add(1);
    out.add(2);
}

You cannot add to a List<? extends Number> (the compiler does not know which subtype it actually holds), and you can only read Object out of a List<? super Integer>.

PECS: producer extends, consumer super

A mnemonic from Joshua Bloch. If a parameter produces values for your code, use extends. If it consumes values from your code, use super. If it does both, use the exact type.

public static <T> void copy(List<? extends T> src, List<? super T> dst) {
    for (T t : src) dst.add(t);
}

src produces Ts, dst consumes them. This signature accepts a wide range of combinations safely.

Type erasure

At runtime, generic types are erased. A Box<String> and a Box<Integer> are both just Box. Consequences:

  1. You cannot do new T(). The runtime does not know what T is.
  2. You cannot use instanceof Box<String>. Only instanceof Box or the pattern form.
  3. You cannot create generic arrays directly: new T[10] is illegal.
  4. Two methods cannot overload only on different generic parameters; after erasure they have the same signature.

Workarounds:

// Pass a Class token to recover the type at runtime
public static <T> T parse(String s, Class<T> type) { /* ... */ }

// Use Object[] internally and cast at the boundary
@SuppressWarnings("unchecked")
T[] arr = (T[]) new Object[10];

Raw types

A raw type is a generic class used without its type argument: just List instead of List<String>. They exist only for backward compatibility with pre-Java 5 code. Using them defeats type safety and triggers unchecked warnings.

Bad:

List names = new ArrayList();   // raw, avoid
names.add(42);
String s = (String) names.get(0);   // ClassCastException at runtime

Good:

List<String> names = new ArrayList<>();

If you must interoperate with old code, use List<?> instead of raw List; you get the “unknown type” semantics without losing the type system.

Generic interfaces

public interface Repository<E, ID> {
    E findById(ID id);
    void save(E entity);
}

public class UserRepo implements Repository<User, Long> {
    public User findById(Long id) { /* ... */ return null; }
    public void save(User u) { /* ... */ }
}

Bind the parameters in the implementation. The compiler tracks the substitutions.

Method references with generics

Most generic method calls are inferred. You rarely need to spell the type out:

List<Integer> nums = Stream.of("1", "2", "3")
        .map(Integer::parseInt)
        .toList();

If inference fails, use the explicit form: Collections.<String>emptyList().

A real world example

A small typed event bus:

public class EventBus {
    private final Map<Class<?>, List<Object>> listeners = new HashMap<>();

    public <T> void on(Class<T> type, java.util.function.Consumer<T> handler) {
        listeners.computeIfAbsent(type, k -> new ArrayList<>()).add(handler);
    }

    @SuppressWarnings("unchecked")
    public <T> void emit(T event) {
        var handlers = listeners.getOrDefault(event.getClass(), List.of());
        for (Object h : handlers) {
            ((java.util.function.Consumer<T>) h).accept(event);
        }
    }
}

The single unchecked cast is contained inside emit. Callers stay type safe.

Wrap up

Use type parameters on classes and methods you author, lean on wildcards at API boundaries, and remember PECS when you write helpers. Generics combine with Streams and Lambdas to make Java surprisingly expressive, even before you touch any frameworks.