Generics in Java

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.

Continue learning