Java Generics code on a dark terminal showing wildcards and bounded type parameters
Java

Java Generics Deep Dive: Wildcards, Bounds, and Type Erasure

A comprehensive guide to Java Generics — covering bounded wildcards, PECS principle, type erasure gotchas, and practical patterns for writing reusable, type-safe APIs.

By aigrama
#java#generics#type-safety#jvm

Generics were introduced in Java 5 to provide compile-time type safety without runtime overhead. After twenty years, they remain one of the most misunderstood — and most powerful — features in the language.

Why Generics Exist

Before generics, collections stored raw Object references, requiring explicit casting:

// Pre-generics Java — every get() call is a ClassCastException waiting to happen
List names = new ArrayList();
names.add("Alice");
String name = (String) names.get(0); // OK
names.add(42);                        // Compiles fine, explodes at runtime

With generics:

List<String> names = new ArrayList<>();
names.add("Alice");
names.add(42); // ✗ Compile-time error: incompatible types
String name = names.get(0); // No cast needed

Java Generics Type Hierarchy

The diagram below shows how Java’s Number hierarchy maps to wildcard rules — the foundation of the PECS principle.

Bounded Type Parameters

Upper Bound (extends)

Use <T extends SomeType> when you need to read from a structure — you know everything in the collection is at least a Number.

// Sum any list of numbers: Integer, Double, Long, BigDecimal — all work
public static double sum(List<? extends Number> numbers) {
    double total = 0;
    for (Number n : numbers) {
        total += n.doubleValue();
    }
    return total;
}

// Usage
List<Integer> ints = List.of(1, 2, 3);
List<Double>  dbs  = List.of(1.1, 2.2, 3.3);
System.out.println(sum(ints)); // 6.0
System.out.println(sum(dbs));  // 6.6

Lower Bound (super)

Use <T super SomeType> when you need to write into a structure.

// Add integers into any list capable of holding them
public static void addNumbers(List<? super Integer> list) {
    for (int i = 1; i <= 5; i++) {
        list.add(i);
    }
}

List<Number> numbers = new ArrayList<>();
addNumbers(numbers); // Works — Number is a supertype of Integer

The PECS Principle

Producer Extends, Consumer Super

// Producer: we READ from source → extends
public static <T> void copy(List<? extends T> source, List<? super T> dest) {
    for (T item : source) {
        dest.add(item);
    }
}

Type Erasure

Java generics are erased at compile time. The JVM only sees raw types at runtime.

List<String> strings = new ArrayList<>();
List<Integer> ints   = new ArrayList<>();

// Both have the same runtime class!
System.out.println(strings.getClass() == ints.getClass()); // true

// This will NOT compile — cannot overload on generic type alone
// public void process(List<String> l) {}
// public void process(List<Integer> l) {} // ✗ Erasure conflict

Practical Implications

// ✗ Cannot create generic arrays
T[] arr = new T[10]; // Compile error

// ✓ Use ArrayList instead
List<T> list = new ArrayList<>();

// ✗ Cannot use instanceof with parameterized types
if (obj instanceof List<String>) {} // Compile error

// ✓ Use raw type with instanceof, then cast carefully
if (obj instanceof List<?> rawList) {
    // Safe unchecked cast (document with @SuppressWarnings)
    @SuppressWarnings("unchecked")
    List<String> strings = (List<String>) rawList;
}

Generic Methods

// A generic utility that works on any Comparable type
public static <T extends Comparable<T>> T max(T a, T b) {
    return a.compareTo(b) >= 0 ? a : b;
}

System.out.println(max(10, 20));         // 20
System.out.println(max("apple", "zen")); // zen

Summary Table

WildcardReadWriteUse case
<T>Generic methods with full control
<? extends T>Read-only producers (PECS: Producer)
<? super T>✗ (Object)Write-only consumers (PECS: Consumer)
<?>✓ (Object)Unknown type — read only as Object

Mastering generics unlocks the ability to write reusable, type-safe libraries that compose elegantly across the Java ecosystem.