Java Generics - Generic Classes, Wildcards and Type Erasure

1. What are Generics?

Generics, introduced in Java 5, allow you to write classes, interfaces, and methods that work with any type while providing compile-time type safety. Before generics, developers used raw types — collections and containers operated on Object, requiring explicit casts everywhere.

The Problem Before Generics

// Pre-generics: raw type List stores Object
List names = new ArrayList();
names.add("Alice");
names.add("Bob");
names.add(42);          // accidentally added an Integer — no compile error!

// Explicit cast needed — fails at RUNTIME if type is wrong
String first = (String) names.get(2);  // ClassCastException at runtime!
Exception in thread "main" java.lang.ClassCastException:
  class java.lang.Integer cannot be cast to class java.lang.String

The Solution: Generics

// With generics: compiler enforces type correctness
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
// names.add(42);  // COMPILE ERROR — Integer is not a String

String first = names.get(0);  // no cast needed, always safe
Alice

Key benefit: Errors are caught at compile time, not at runtime. This makes your code safer, eliminates manual casting, and improves readability.

2. Generic Classes

A generic class declares one or more type parameters in angle brackets after the class name. By convention, T stands for "Type". The type parameter acts as a placeholder that gets replaced with a real type when the class is instantiated.

// Generic class with type parameter T
public class Box<T> {
    private T value;

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

    public T getValue() {
        return value;
    }

    public void setValue(T value) {
        this.value = value;
    }

    @Override
    public String toString() {
        return "Box[" + value + "]";
    }
}

public class Main {
    public static void main(String[] args) {
        Box<String> stringBox = new Box<>("Hello Generics");
        Box<Integer> intBox   = new Box<>(100);

        System.out.println(stringBox.getValue());       // no cast needed
        System.out.println(intBox.getValue() * 2);

        // stringBox.setValue(123);  // COMPILE ERROR — must be String
    }
}
Hello Generics
200

3. Generic Methods

A method can declare its own type parameters, independent of any class-level type parameters. The type parameter list appears before the return type.

public class GenericUtils {

    // T is the method's own type parameter
    public static <T> void printArray(T[] array) {
        for (T element : array) {
            System.out.print(element + " ");
        }
        System.out.println();
    }

    // Returns the larger of two Comparable values
    public static <T extends Comparable<T>> T max(T a, T b) {
        return (a.compareTo(b) >= 0) ? a : b;
    }

    public static void main(String[] args) {
        Integer[] nums = {1, 2, 3, 4, 5};
        String[]  strs = {"banana", "apple", "cherry"};

        printArray(nums);  // T inferred as Integer
        printArray(strs);  // T inferred as String

        System.out.println(max(10, 20));         // 20
        System.out.println(max("cat", "dog"));   // dog
    }
}
1 2 3 4 5
banana apple cherry
20
dog

4. Generic Interfaces

Interfaces can also be parameterized with type parameters. Implementing classes either provide a concrete type or pass the type parameter through.

// Generic interface with two type parameters
public interface Pair<K, V> {
    K getKey();
    V getValue();
}

// Concrete implementation
public class OrderedPair<K, V> implements Pair<K, V> {
    private final K key;
    private final V value;

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

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

    @Override
    public String toString() {
        return "(" + key + ", " + value + ")";
    }
}

public class Main {
    public static void main(String[] args) {
        Pair<String, Integer> p1 = new OrderedPair<>("Alice", 30);
        Pair<String, String>  p2 = new OrderedPair<>("country", "Java");
        System.out.println(p1);
        System.out.println(p2);
    }
}
(Alice, 30)
(country, Java)

5. Bounded Type Parameters

Bounds restrict the types that can be used as type arguments. Use extends for upper bounds (works for both classes and interfaces).

Upper Bound: <T extends Number>

Restricts T to Number or any of its subclasses (Integer, Double, etc.), allowing access to Number's methods.

public class Stats<T extends Number> {
    private T[] data;

    public Stats(T[] data) { this.data = data; }

    public double average() {
        double sum = 0;
        for (T val : data) {
            sum += val.doubleValue();  // doubleValue() is from Number
        }
        return sum / data.length;
    }
}

public class Main {
    public static void main(String[] args) {
        Integer[] ints    = {2, 4, 6, 8, 10};
        Double[]  doubles = {1.5, 2.5, 3.5};

        System.out.println(new Stats<>(ints).average());    // 6.0
        System.out.println(new Stats<>(doubles).average()); // 2.5

        // Stats<String> s = new Stats<>(...); // COMPILE ERROR
    }
}
6.0
2.5

Multiple Bounds

A type parameter can have multiple bounds using &. The class bound (if any) must come first.

// T must extend Comparable AND implement Serializable
public <T extends Comparable<T> & java.io.Serializable> T clamp(T val, T min, T max) {
    if (val.compareTo(min) < 0) return min;
    if (val.compareTo(max) > 0) return max;
    return val;
}

6. Wildcards

The wildcard ? represents an unknown type. It is used in method parameters (not class declarations) to increase flexibility when you need to accept a range of parameterized types.

Wildcard Name Meaning Use When
<?> Unbounded Any type Only using Object methods; read-only iteration
<? extends T> Upper bounded T or any subtype of T Reading/consuming data (producer)
<? super T> Lower bounded T or any supertype of T Writing/supplying data (consumer)

Unbounded Wildcard

public static void printList(List<?> list) {
    for (Object item : list) {
        System.out.print(item + " ");
    }
    System.out.println();
}

printList(List.of(1, 2, 3));
printList(List.of("a", "b", "c"));
1 2 3
a b c

Upper Bounded Wildcard

// Accepts List<Integer>, List<Double>, List<Number>, etc.
public static double sumOfList(List<? extends Number> list) {
    double sum = 0;
    for (Number n : list) sum += n.doubleValue();
    return sum;
}

System.out.println(sumOfList(List.of(1, 2, 3)));          // 6.0
System.out.println(sumOfList(List.of(1.5, 2.5, 3.0)));    // 7.0
6.0
7.0

Lower Bounded Wildcard

// Accepts List<Integer>, List<Number>, List<Object>
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);
System.out.println(numbers);
[1, 2, 3, 4, 5]

PECS Principle: "Producer Extends, Consumer Super." If a parameterized type produces T values (you read from it), use <? extends T>. If it consumes T values (you write to it), use <? super T>.

7. Type Erasure

Java generics are a compile-time mechanism only. The Java compiler uses type information to perform type checks and insert casts, then erases all type parameters from the bytecode. At runtime, a List<String> and a List<Integer> are both just List.

// Source code (what you write)
List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0);

// After type erasure (what the JVM sees)
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0);  // compiler inserts this cast

Implications of type erasure:

  • You cannot use instanceof with a parameterized type: obj instanceof List<String> is a compile error.
  • You cannot create instances of a type parameter: new T() is not allowed.
  • You cannot create generic arrays: new T[10] is not allowed directly.
  • Static fields of a generic class cannot use the type parameter.
// This is legal — checks erased type
if (list instanceof List<?>) { ... }

// This is NOT legal — type erased at runtime
// if (list instanceof List<String>) { ... }  // COMPILE ERROR

Heap pollution: Mixing raw types and generic types can lead to unchecked warnings and unexpected ClassCastException at runtime because the compiler cannot guarantee type safety across raw-type boundaries.

8. Generic Collections

The Java Collections Framework was retrofitted with generics in Java 5. Always use parameterized collections — never use raw types in new code.

import java.util.*;

public class GenericCollections {
    public static void main(String[] args) {
        // Typed List — compiler prevents adding wrong types
        List<String> languages = new ArrayList<>();
        languages.add("Java");
        languages.add("Kotlin");
        languages.add("Scala");
        // languages.add(42);  // COMPILE ERROR

        for (String lang : languages) {
            System.out.println(lang.toUpperCase());  // no cast needed
        }

        // Typed Map
        Map<String, Integer> scores = new HashMap<>();
        scores.put("Alice", 95);
        scores.put("Bob", 87);

        for (Map.Entry<String, Integer> entry : scores.entrySet()) {
            System.out.println(entry.getKey() + ": " + entry.getValue());
        }
    }
}
JAVA
KOTLIN
SCALA
Alice: 95
Bob: 87

9. Multiple Type Parameters

A generic class or method can declare multiple type parameters, separated by commas. The standard library's Map.Entry<K, V> is a classic example.

// Generic Pair class with two 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; }

    public static <K, V> Pair<K, V> of(K k, V v) {
        return new Pair<>(k, v);
    }

    @Override
    public String toString() {
        return key + " -> " + value;
    }
}

public class Main {
    public static void main(String[] args) {
        Pair<String, Integer>  nameAge   = Pair.of("Alice", 30);
        Pair<String, Boolean>  feature   = Pair.of("darkMode", true);
        Pair<Integer, Double>  ratio     = Pair.of(1, 1.618);

        System.out.println(nameAge);
        System.out.println(feature);
        System.out.println(ratio);

        // Map.Entry is Java's built-in two-parameter generic
        Map<String, List<Integer>> groupedScores = new HashMap<>();
        groupedScores.put("math", List.of(90, 85, 92));
        for (Map.Entry<String, List<Integer>> e : groupedScores.entrySet()) {
            System.out.println(e.getKey() + ": " + e.getValue());
        }
    }
}
Alice -> 30
darkMode -> true
1 -> 1.618
math: [90, 85, 92]

10. Common Conventions

Java uses single uppercase letters as type parameter names by convention. Following these conventions makes generic code instantly recognizable to other Java developers.

Letter Stands For Typical Usage
T Type General-purpose type parameter: Box<T>, Comparable<T>
E Element Elements in a collection: List<E>, Set<E>
K Key Map key type: Map<K, V>
V Value Map value type: Map<K, V>, Cache<K, V>
N Number Numeric type parameters: Stats<N extends Number>
R Result / Return Function return types: Function<T, R>
S, U, W Secondary types Second, third, fourth type params when T is already used
// Convention examples from the standard library
interface Comparable<T>          // T = Type
interface Collection<E>          // E = Element
interface Map<K, V>              // K = Key, V = Value
interface Function<T, R>         // T = input Type, R = Return type
interface BiFunction<T, U, R>    // T, U = inputs, R = return

Good practice: Always parameterize your collections and containers. Enable the compiler warning -Xlint:unchecked (or the equivalent IDE warning) to catch any accidental raw-type usage in your codebase.