Java Generics - A Comprehensive Guide

A comprehensive guide to Java generics, type erasure, and wildcards.

🌱 Seedling·created: ·category:Java

Note: Generics were introduced in Java 5, and the core mechanism (type erasure) hasn’t changed since. Java 25 doesn’t add new language features specific to generics, but modern features like record, sealed classes, pattern matching for switch, and var are commonly combined with generics. This guide covers both the classic rules and how they interact with these modern features.

Table of Contents

  1. What Are Generics and Why Use Them?
  2. Generic Classes
  3. Generic Methods
  4. Bounded Type Parameters
  5. Wildcards (?, ? extends, ? super) and the PECS Rule
  6. Generic Interfaces
  7. Type Erasure - Behind the Scenes
  8. Generics and Arrays
  9. Generic Constructors
  10. Recursive (Self-Referencing) Generics
  11. Generics with Varargs
  12. Static Members and the Generics Restriction
  13. Generics in the Java 25 Ecosystem: Records, Sealed Types, Pattern Matching
  14. Real-World Scenarios
  15. Generics and Casting
  16. Common Mistakes

1. What Are Generics and Why Use Them?

Generics allow classes, interfaces, and methods to be defined with type parameters. The goals are:

  • Compile-time type safety.
  • Eliminating unnecessary casts.
  • Making code reusable across many types.

Without generics (pre-Java 5 style):

List list = new ArrayList();
list.add("hello");
list.add(42); // compiles fine, but it's a logic bug!
String s = (String) list.get(1); // ClassCastException at runtime!

With generics:

List<String> list = new ArrayList<>();
list.add("hello");
list.add(42); // COMPILE ERROR - caught immediately
String s = list.get(0); // no cast needed

2. Generic Classes

A class can declare one or more type parameters in angle brackets. Conventional names: T (Type), E (Element), K (Key), V (Value), N (Number), R (Result).

public class Box<T> {
    private T content;

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

    public T getContent() {
        return content;
    }

    public void setContent(T content) {
        this.content = content;
    }
}

Usage:

Box<String> stringBox = new Box<>("Hello World");
Box<Integer> numberBox = new Box<>(2026);

System.out.println(stringBox.getContent().toUpperCase());
System.out.println(numberBox.getContent() + 1);

Multiple Type Parameters

public class Pair<K, V> {
    private final K key;
    private final V value;

    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public K getKey() { return key; }
    public V getValue() { return value; }

    @Override
    public String toString() {
        return "(" + key + " -> " + value + ")";
    }
}
Pair<String, Integer> ageInfo = new Pair<>("Alice", 30);
System.out.println(ageInfo); // (Alice -> 30)

3. Generic Methods

A method can declare its own type parameter even if its enclosing class isn’t generic. The type parameter goes right before the return type.

public class Utils {
    public static <T> T firstElement(List<T> list) {
        return list.get(0);
    }

    public static <T> void printList(List<T> list) {
        for (T item : list) {
            System.out.println(item);
        }
    }

    // Multiple type parameters
    public static <K, V> boolean hasKey(Map<K, V> map, K key) {
        return map.containsKey(key);
    }
}

Usage:

List<String> names = List.of("Alice", "Bob", "Carol");
String first = Utils.<String>firstElement(names); // type can be explicit
String first2 = Utils.firstElement(names); // or inferred by the compiler

4. Bounded Type Parameters

You can restrict a type parameter to extend a class or implement an interface.

Upper Bound - extends

public class NumberBox<T extends Number> {
    private T number;

    public NumberBox(T number) {
        this.number = number;
    }

    public double doubled() {
        return number.doubleValue() * 2;
    }
}
NumberBox<Integer> box = new NumberBox<>(10);
System.out.println(box.doubled()); // 20.0

// NumberBox<String> errorBox = new NumberBox<>("hello"); // COMPILE ERROR

Multiple Bounds

interface Rankable<T> {
    int rank(T other);
}

// At most one class, any number of interfaces; class must come first
public class Hybrid<T extends Number & Rankable<T>> {
    private T value;
    // ...
}

5. Wildcards (?, ? extends, ? super) and the PECS Rule

A wildcard represents an unknown type and is mostly used to make method parameters more flexible.

Unbounded Wildcard: List<?>

public static void printSize(List<?> list) {
    System.out.println("Size: " + list.size());
    // list.add(...) is not allowed (except null), since the type is unknown
}

Upper-Bounded Wildcard: ? extends T - Read-Only (Producer)

public static double sumAll(List<? extends Number> numbers) {
    double total = 0;
    for (Number n : numbers) {
        total += n.doubleValue();
    }
    return total;
}
List<Integer> ints = List.of(1, 2, 3);
List<Double> doubles = List.of(1.5, 2.5);

System.out.println(sumAll(ints));    // 6.0
System.out.println(sumAll(doubles)); // 4.0

Lower-Bounded Wildcard: ? super T - Write-Only (Consumer)

public static void addIntegers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
    list.add(3);
}
List<Number> numbers = new ArrayList<>();
addIntegers(numbers); // works because Number is a supertype of Integer

List<Object> objects = new ArrayList<>();
addIntegers(objects); // Object is a supertype of everything

The PECS Rule: Producer Extends, Consumer Super

  • If a structure produces (you read from it) → use ? extends T
  • If a structure consumes (you write to it) → use ? super T
  • If you both read and write → don’t use a wildcard, use a concrete type T.

Collections.copy’s signature is the textbook example of this rule:

public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    for (int i = 0; i < src.size(); i++) {
        dest.set(i, src.get(i));
    }
}

Wildcards Aren’t Just for Collections: Class<?>, Optional<?>, Comparator<?>

Wildcards (?) aren’t limited to collection types like List or Map - any generic type can use a wildcard. One of the most common examples is Class<?>.

Class<?> anyType = String.class; // a Class reference whose type parameter is unknown (or irrelevant)
Class<?> anotherType = Integer.class;
Class<?> thirdType = MyCustomClass.class;

Why Class<?> instead of Class<Object>? Because due to invariance, Class<Integer> cannot be assigned to Class<Object> - but it can always be assigned to Class<?>:

Class<Integer> intClass = Integer.class;
// Class<Object> objClass = intClass; // COMPILE ERROR - invariance
Class<?> anyClass = intClass; // this works - a wildcard accepts any generic type

This is why Class<?> shows up so often in reflection-heavy code, frameworks, and in the EventBus example from an earlier section (Map<Class<?>, List<Consumer<?>>>) - it’s a way of saying “some type, we don’t care which one right now, but it is a type.”

The same idea applies to other generic types too:

public static void printValue(Optional<?> value) {
    if (value.isPresent()) {
        System.out.println(value.get()); // read as Object, but that's enough to print it
    }
}

public static void sortList(List<?> list, Comparator<?> comparator) {
    // Note: this signature isn't very useful in practice because
    // the compiler can't relate the ? of List<?> to the ? of Comparator<?>.
    // A generic method is usually preferred instead: <T> void sort(List<T> list, Comparator<T> c)
}

Using Type Information from Class<?>: isInstance and cast

Even with just a Class<?> reference, you can perform safe type checks using the Class object’s own methods (isInstance, cast) - see Section 15.3.

public static boolean typeMatches(Class<?> type, Object obj) {
    return type.isInstance(obj); // e.g. String.class.isInstance("hello") -> true
}

6. Generic Interfaces

public interface Store<T> {
    void add(T item);
    T get(int index);
    int size();
}

public class ListStore<T> implements Store<T> {
    private final List<T> items = new ArrayList<>();

    @Override
    public void add(T item) { items.add(item); }

    @Override
    public T get(int index) { return items.get(index); }

    @Override
    public int size() { return items.size(); }
}

You can also implement an interface with a concrete type:

public class StringOnlyStore implements Store<String> {
    private final List<String> items = new ArrayList<>();

    @Override
    public void add(String item) { items.add(item); }

    @Override
    public String get(int index) { return items.get(index); }

    @Override
    public int size() { return items.size(); }
}

Familiar examples from the standard library: Comparable<T>, Comparator<T>, Iterable<T>, Function<T, R>, Supplier<T>, Consumer<T>.


7. Type Erasure - Behind the Scenes

Java enforces generics at compile time; at runtime (in bytecode), most generic type information is erased. This is called type erasure and it was designed to preserve backward compatibility with pre-Java 5 code.

List<String> stringList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();

System.out.println(stringList.getClass() == intList.getClass()); // true!

The compiler replaces an unbounded T with Object, and a bounded T extends Number with Number, inserting the necessary casts automatically.

Consequences of Type Erasure

  1. You cannot instantiate a generic type parameter directly:

    public class Box<T> {
        // T value = new T(); // COMPILE ERROR
    }
  2. You cannot check a parameterized type with instanceof:

    List<String> list = new ArrayList<>();
    // if (list instanceof List<String>) {} // COMPILE ERROR
    if (list instanceof List<?>) {} // this is valid
  3. You cannot create a generic array (see Section 8).

  4. Static members cannot reference the class’s type parameter (see Section 12).

You Can’t Get Class<List<String>>: Super Type Tokens

One of the most frustrating consequences of type erasure: there is no separate Class object for List<String>. At runtime there’s only a single List.class (raw); List<String> and List<Integer> share the exact same Class object.

Class<List<String>> type = List<String>.class; // COMPILE ERROR - no such thing exists!

The most common (but information-losing) “workaround” is to fall back on the raw type:

@SuppressWarnings("unchecked")
Class<List> rawType = (Class<List>) (Class<?>) List.class; // the element type (String, Integer...) is completely gone

The real solution is the Super Type Token pattern (popularized by Neal Gafter). The idea: if you derive an anonymous subclass from a generic class, the compiler embeds the superclass’s generic signature (ParameterizedType) into that subclass’s .class file. Even though this information is erased for normal instances, it’s preserved at the class-declaration level and can be read via reflection.

public abstract class TypeReference<T> {
    private final Type type;

    protected TypeReference() {
        Type superclass = getClass().getGenericSuperclass();
        this.type = ((ParameterizedType) superclass).getActualTypeArguments()[0];
    }

    public Type getType() {
        return type;
    }
}

Usage - note that the trailing {} is essential: this is the entire trick, since it creates an anonymous subclass whose generic superclass information is stored in the class file:

TypeReference<List<String>> ref = new TypeReference<List<String>>() {}; // anonymous subclass!
Type type = ref.getType(); // we now have the full "List<String>" info at runtime
System.out.println(type); // java.util.List<java.lang.String>

This pattern is so useful that many libraries ship their own version of it:

  • Jackson: com.fasterxml.jackson.core.type.TypeReference<T>
  • Guava: com.google.common.reflect.TypeToken<T>
  • Spring: org.springframework.core.ParameterizedTypeReference<T>

A real-world example - deserializing a generic list from JSON with Jackson:

ObjectMapper mapper = new ObjectMapper();

// This does NOT work: the mapper has no way to know the generic parameter (String), deserializes as raw List
// List<User> list = mapper.readValue(json, List.class);

// This DOES work: TypeReference carries the full type info via the anonymous-subclass trick
List<User> list = mapper.readValue(json, new TypeReference<List<User>>() {});

In short: Class<T> can only represent reifiable types (types whose full information is available at runtime) - meaning concrete classes like String, Integer, and raw generic types like List, but not parameterized types like List<String>. When you need the full type information of a parameterized type, you need java.lang.reflect.Type (typically wrapped in a Super Type Token) instead of Class<T>.


8. Generics and Arrays

You cannot create an array of a generic type directly in Java, because arrays are reifiable (they know their element type at runtime), whereas generics are not (due to erasure).

public class ArrayExample<T> {
    // private T[] array = new T[10]; // COMPILE ERROR

    @SuppressWarnings("unchecked")
    private T[] array = (T[]) new Object[10]; // common workaround, suppresses warning
}

List<T> is generally preferred instead. If an array is truly required, you can use Array.newInstance with a Class<T> parameter:

@SuppressWarnings("unchecked")
public static <T> T[] createArray(Class<T> type, int size) {
    return (T[]) java.lang.reflect.Array.newInstance(type, size);
}

// Usage:
String[] stringArray = createArray(String.class, 5);

9. Generic Constructors

A class doesn’t need to be generic itself for its constructor to declare its own type parameter.

public class Logger {
    private String tag;

    public <T> Logger(T initialValue) {
        this.tag = "Initial: " + initialValue.toString();
    }
}
Logger l1 = new Logger(42);
Logger l2 = new Logger("hello");

10. Recursive (Self-Referencing) Generics

Also known as the “Curiously Recurring Generic Pattern.” Commonly used for fluent builders or comparing objects against their own concrete type.

public abstract class Shape<T extends Shape<T>> implements Comparable<T> {
    protected double area;

    @SuppressWarnings("unchecked")
    public T scale(double factor) {
        this.area *= factor;
        return (T) this;
    }
}

public class Circle extends Shape<Circle> {
    @Override
    public int compareTo(Circle other) {
        return Double.compare(this.area, other.area);
    }
}
Circle c = new Circle();
c.scale(2.0).scale(1.5); // method chaining, type stays Circle

Enum<E extends Enum<E>> is the best-known example in the standard library.


11. Generics with Varargs

@SafeVarargs
public static <T> List<T> listOf(T... items) {
    return Arrays.asList(items);
}
List<String> list = listOf("one", "two", "three");

The @SafeVarargs annotation suppresses “heap pollution” warnings that can occur with generic varargs; it can only be applied to static, final, or private methods.


12. Static Members and the Generics Restriction

A class’s type parameter (T) cannot be used directly in static fields or static methods, because T is tied to each object instance, while static members belong to the class itself and exist before any instance (and thus any type binding) exists.

public class Box<T> {
    // private static T defaultValue; // COMPILE ERROR

    // But a static generic method can define its own independent type parameter:
    public static <U> Box<U> emptyBox() {
        return new Box<>();
    }
}

13. Generics in the Java 25 Ecosystem: Records, Sealed Types, Pattern Matching

Generics themselves haven’t changed in Java 25, but combined with modern language features, code can be far more expressive and safe.

Generic Records

public record Result<T>(T value, boolean success, String message) {
    public static <T> Result<T> success(T value) {
        return new Result<>(value, true, "OK");
    }

    public static <T> Result<T> failure(String message) {
        return new Result<>(null, false, message);
    }
}
Result<Integer> result = Result.success(42);
if (result.success()) {
    System.out.println("Value: " + result.value());
}

Sealed Interfaces + Generics + Pattern Matching for Switch

public sealed interface Outcome<T> permits Ok, Err {}
public record Ok<T>(T value) implements Outcome<T> {}
public record Err<T>(String errorMessage) implements Outcome<T> {}

public static <T> String handleOutcome(Outcome<T> outcome) {
    return switch (outcome) {
        case Ok<T> ok -> "Success: " + ok.value();
        case Err<T> err -> "Error: " + err.errorMessage();
    };
}

This lets you write exhaustive, type-safe switch expressions while preserving the generic type parameter - the compiler verifies all cases are covered without needing a default branch.

var with Generics

var list = new ArrayList<Map<String, List<Integer>>>(); // shortens a long type declaration

var performs type inference, but the generic type information is preserved - it’s purely a syntactic convenience, not a trade-off against type safety.


14. Real-World Scenarios

Scenario 1: Generic Repository Pattern

public interface Repository<T, ID> {
    T findById(ID id);
    List<T> findAll();
    T save(T entity);
    void deleteById(ID id);
}

public class UserRepository implements Repository<User, Long> {
    private final Map<Long, User> store = new HashMap<>();

    @Override
    public User findById(Long id) { return store.get(id); }

    @Override
    public List<User> findAll() { return new ArrayList<>(store.values()); }

    @Override
    public User save(User entity) {
        store.put(entity.getId(), entity);
        return entity;
    }

    @Override
    public void deleteById(Long id) { store.remove(id); }
}

Scenario 2: Generic Event Bus

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

    public <T> void subscribe(Class<T> type, Consumer<T> listener) {
        listeners.computeIfAbsent(type, k -> new ArrayList<>()).add(listener);
    }

    @SuppressWarnings("unchecked")
    public <T> void publish(T event) {
        List<Consumer<?>> list = listeners.get(event.getClass());
        if (list != null) {
            for (Consumer<?> l : list) {
                ((Consumer<T>) l).accept(event);
            }
        }
    }
}

Scenario 3: Generic Builder Pattern (with Recursive Generics)

public abstract class QueryBuilder<T extends QueryBuilder<T>> {
    protected StringBuilder query = new StringBuilder("SELECT * FROM table");

    @SuppressWarnings("unchecked")
    public T where(String condition) {
        query.append(" WHERE ").append(condition);
        return (T) this;
    }

    public String build() { return query.toString(); }
}

public class UserQueryBuilder extends QueryBuilder<UserQueryBuilder> {
    public UserQueryBuilder activeUsers() {
        return where("active = true");
    }
}

Scenario 4: An Either-like Wrapper

public sealed interface Either<L, R> permits Left, Right {}
public record Left<L, R>(L value) implements Either<L, R> {}
public record Right<L, R>(R value) implements Either<L, R> {}

Scenario 5: Generic Caching Layer

public class Cache<K, V> {
    private final Map<K, V> store = new ConcurrentHashMap<>();
    private final Function<K, V> loader;

    public Cache(Function<K, V> loader) {
        this.loader = loader;
    }

    public V get(K key) {
        return store.computeIfAbsent(key, loader);
    }
}
Cache<String, User> userCache = new Cache<>(id -> loadFromDatabase(id));
User u = userCache.get("user-123");

15. Generics and Casting

Casting with generic collections is one of the most common - and most confusing - topics because of type erasure. This section walks through all the scenarios with examples.

15.1. Why Can’t List<String> Be Cast to List<Object>? (Invariance)

Generic types in Java are invariant: even though String is a subtype of Object, List<String> is not a subtype of List<Object>.

List<String> stringList = new ArrayList<>();
// List<Object> objectList = stringList; // COMPILE ERROR

List<Object> objectList = (List<Object>) (List<?>) stringList; // can be forced with a "double cast"

If this restriction didn’t exist, the following would be possible:

List<String> stringList = new ArrayList<>();
List<Object> objectList = stringList; // if this were allowed...
objectList.add(42); // an Integer would get added!
String s = stringList.get(0); // ClassCastException! (Integer -> String)

15.2. Unchecked Casts: Casting with (T)

Because of type erasure, the compiler doesn’t know T’s actual runtime type. So when you cast an Object directly to T, the compiler only issues a warning, not an error - because it genuinely cannot verify it:

public class Box<T> {
    private Object content;

    @SuppressWarnings("unchecked")
    public T getContent() {
        return (T) content; // unchecked cast warning
    }
}

This cast is not actually checked at runtime - if content is the wrong type, the failure doesn’t happen when getContent() is called, but later, when the returned value is assigned somewhere that expects a different type (a delayed ClassCastException):

Box rawBox = new Box(); // raw type usage - dangerous!
rawBox.content = 42;
Box<String> stringBox = rawBox; // compiler warns but allows it
String s = stringBox.getContent(); // ClassCastException blows up HERE, not at the cast site!

15.3. Class<T>.cast() - A Safe Runtime Cast

For a reflection-based cast that is actually checked, use Class<T>.cast():

public static <T> T safeCast(Class<T> type, Object obj) {
    return type.cast(obj); // throws ClassCastException immediately if obj doesn't match
}
Object value = "hello";
String s = safeCast(String.class, value); // safe, checked immediately

Object wrongValue = 42;
String s2 = safeCast(String.class, wrongValue); // ClassCastException - thrown IMMEDIATELY

This is much safer than an unchecked (T) obj cast, because the error is caught right where the problem originates, rather than surfacing somewhere else later.

15.4. Collections.checkedList / checkedMap

To catch a mistakenly-added wrong-typed element (e.g. via a raw type) at runtime, right at the point of insertion:

List<String> stringList = new ArrayList<>();
List<String> checkedList = Collections.checkedList(stringList, String.class);

List rawList = checkedList; // accessed through a raw type
rawList.add(42); // ClassCastException thrown IMMEDIATELY on add, not later!

With a plain ArrayList<String>, rawList.add(42) would compile with a warning, but the error would only surface later when reading via get() (at the cast site). checkedList catches this error at the moment of insertion.

15.5. Wildcard Capture and Casting

You can’t call set()/add() directly on a List<?> because the compiler doesn’t know the actual type. This is resolved with a helper generic method - a technique called “wildcard capture”:

public static void reverse(List<?> list) {
    reverseHelper(list);
}

// The helper method "captures" the ? as T
private static <T> void reverseHelper(List<T> list) {
    List<T> copy = new ArrayList<>(list);
    Collections.reverse(copy);
    for (int i = 0; i < list.size(); i++) {
        ((List<T>) list).set(i, copy.get(i)); // now known as T, cast is safe
    }
}

15.6. Casting Object[] to T[] (Heap Pollution Risk)

@SuppressWarnings("unchecked")
public static <T> T[] listToArray(List<T> list, T[] template) {
    return list.toArray(template); // performs a safe cast internally
}

But be careful with casts you write by hand:

public static <T> T[] dangerousArrayCreation(int size) {
    return (T[]) new Object[size]; // RISK OF ClassCastException AT RUNTIME
}

// Usage:
String[] arr = dangerousArrayCreation(5); // blows up here! Object[] -> String[] cast is invalid

This is the textbook example of heap pollution: the compiler allows the cast (with a warning), but since the array’s actual runtime type is Object[], passing it somewhere that expects a String[] triggers a ClassCastException.

15.7. Casting from Raw Types to Generic Types (Legacy Code Integration)

A common scenario when working with old (pre-generics) APIs:

@SuppressWarnings("unchecked")
public List<String> adaptLegacyApi(Object legacyResult) {
    // legacyResult is actually a raw List, but arrives typed as Object (e.g. from a library)
    List rawList = (List) legacyResult;
    return (List<String>) rawList; // a two-step unchecked cast
}

For casts like this, it’s good practice to scope @SuppressWarnings("unchecked") as narrowly as possible (to the specific variable rather than the whole method), so other potential issues aren’t silently hidden along with it.

15.8. Summary Table: When Is a Cast Safe?

Cast TypeCompile-Time CheckRuntime CheckSafety
(T) object (direct unchecked cast)Warns, but allows itNone - fails later, elsewhereLow
Class<T>.cast(object)CompilesYes - immediate ClassCastExceptionHigh
Collections.checkedList/checkedMapCompilesYes - checked at insertion timeHigh
List<Object> x = (List<Object>)(List<?>) yWarns, but allows itNone (bypasses invariance)Low - use with care
(T[]) new Object[n]Warns, but allows itNone - fails if the array escapesLow - prefer List<T> when possible

16. Common Mistakes

MistakeExplanation
Trying to write new T()Not possible due to type erasure; use Supplier<T> or Class<T> + reflection
Creating a generic array like List<T>[]Not possible; use List<List<T>> or a @SuppressWarnings workaround
Calling add() on a List<? extends T>? extends is read-only; remember the PECS rule
Using T in a static fieldStatic members cannot access the class’s type parameter
Using raw types (List instead of List<String>)You lose type safety entirely; the compiler will warn you
Writing instanceof List<String>Compile error; only instanceof List<?> is valid

Summary

Generics are one of the cornerstones of Java’s type-safety philosophy. Despite some constraints from type erasure (no array creation, no static field access, no runtime type checks), tools like wildcards, bounded types, and recursive generics allow for flexible, safe API design. As of Java 25, generics work seamlessly alongside modern features like records, sealed interfaces, and pattern matching to enable far more readable and robust codebases.

This note is part of the Digital Garden — a collection of connected, evolving thoughts.