Java Generics

Generics let you write classes, interfaces, and methods that operate on a type parameter instead of a fixed, hard-coded type. Instead of writing a separate StringBox, IntegerBox, and DoubleBox, you write one Box<T> and let the caller decide what T is. The compiler then enforces type safety at compile time, eliminating the runtime ClassCastException surprises that plagued pre-Java-5 code, and it removes the need for manual casting when you retrieve values from collections.

Generics are everywhere in modern Java — every collection in java.util is generic, the Stream API is built on generic functional interfaces, and most well-designed libraries expose generic APIs. Understanding generics thoroughly, including their quirks, is essential to reading and writing idiomatic Java.

Overview: How Generics Work

A generic type is declared with one or more type parameters in angle brackets, conventionally named with single uppercase letters: T (Type), E (Element), K/V (Key/Value), R (Result). When you write Box<String>, String is called a type argument supplied for the type parameter T.

Crucially, Java generics are implemented using type erasure. This means generic type information exists only at compile time; the compiler uses it to check your code, then strips it out, replacing type parameters with their bound (or Object if unbounded) in the compiled .class file. At runtime, a Box<String> and a Box<Integer> are literally the exact same class — Box — there is only one class file. The compiler inserts the necessary casts automatically wherever you retrieve a value, so your code behaves as if the type information were still there, but a running JVM has no idea what T was at any particular call site.

This design choice was made for backward compatibility: generics were added in Java 5, and erasure allowed generic code to interoperate with pre-generics ("raw type") code and bytecode. It also explains several restrictions you’ll hit later, such as not being able to create an array of a generic type, or use instanceof with a type parameter.

Syntax

class ClassName<T> {
    private T field;
    public void set(T value) { field = value; }
    public T get() { return field; }
}

// Multiple type parameters
class Pair<K, V> { K key; V value; }

// Bounded type parameter
class NumericBox<T extends Number> { T value; }

// Generic method
static <T> T firstElement(List<T> list) { return list.get(0); }

// Wildcards
void printAll(List<?> list) { }
void readFrom(List<? extends Number> list) { }
void writeTo(List<? super Integer> list) { }
  • T, E, K, V — type parameter names, placeholders filled in when the type is used.
  • <T extends Number> — a bounded type parameter; T must be Number or a subclass. (extends is used even for interfaces here.)
  • <T extends Comparable&Serializable> — multiple bounds, joined with &; at most one bound may be a class, and it must come first.
  • <?> — an unbounded wildcard, meaning "some unknown type."
  • <? extends Number> — an upper-bounded wildcard: any type that is Number or a subtype. Good for reading data out (a "producer").
  • <? super Integer> — a lower-bounded wildcard: Integer or any supertype. Good for writing data in (a "consumer").
Form Meaning Typical use
List<T> Exact, known type parameter Class/method definitions
List<?> Unknown type, read-only as Object Method just needs to inspect the list generically
List<? extends T> T or any subtype (producer) Reading elements out safely
List<? super T> T or any supertype (consumer) Adding elements in safely

Examples

Example 1: A Generic Box Class

public class Main {
    static class Box<T> {
        private T content;
        public void set(T content) { this.content = content; }
        public T get() { return content; }
    }

    public static void main(String[] args) {
        Box<String> stringBox = new Box<>();
        stringBox.set("Hello Generics");
        System.out.println("String box contains: " + stringBox.get());

        Box<Integer> intBox = new Box<>();
        intBox.set(42);
        System.out.println("Integer box contains: " + intBox.get());
    }
}

Output:

String box contains: Hello Generics
Integer box contains: 42

The same Box class works for both a String and an Integer with no casting required, and no way to accidentally call intBox.set("oops") — the compiler would reject it.

Example 2: A Bounded Generic Method

public class Main {
    static <T extends Comparable<T>> T max(T a, T b) {
        return a.compareTo(b) >= 0 ? a : b;
    }

    public static void main(String[] args) {
        System.out.println("Max of 10 and 25: " + max(10, 25));
        System.out.println("Max of apple and banana: " + max("apple", "banana"));
        System.out.println("Max of 3.5 and 2.1: " + max(3.5, 2.1));
    }
}

Output:

Max of 10 and 25: 25
Max of apple and banana: banana
Max of 3.5 and 2.1: 3.5

The bound T extends Comparable<T> tells the compiler that whatever type is used must implement compareTo, so the method body can call it safely. This one method works for Integer, String, Double, or any custom class implementing Comparable.

Example 3: Wildcards for Flexible APIs

import java.util.Arrays;
import java.util.List;

public class Main {
    static double sumOfList(List<? extends Number> list) {
        double sum = 0.0;
        for (Number n : list) {
            sum += n.doubleValue();
        }
        return sum;
    }

    public static void main(String[] args) {
        List<Integer> integers = Arrays.asList(1, 2, 3, 4);
        List<Double> doubles = Arrays.asList(1.5, 2.5, 3.0);

        System.out.println("Sum of integers: " + sumOfList(integers));
        System.out.println("Sum of doubles: " + sumOfList(doubles));
    }
}

Output:

Sum of integers: 10.0
Sum of doubles: 7.0

Without the wildcard, sumOfList would have to be declared as List<Number>, and neither List<Integer> nor List<Double> would be accepted, because generics are invariantList<Integer> is not a subtype of List<Number> even though Integer is a subtype of Number. The ? extends Number wildcard is what makes the method accept a list of any Number subtype.

Under the Hood: Type Erasure in Action

When javac compiles a generic class, it performs these steps:

  • It checks every use of the type parameter against its declared bound (Object if none was given), rejecting any operation that isn’t valid for that bound.
  • It replaces the type parameter with its erasure (the bound, or Object) throughout the compiled class. Box<T> becomes a class whose field is literally typed Object in the bytecode.
  • It inserts synthetic casts at every point where a value is read back out with the specific type, so String s = box.get() compiles to "get the Object, then cast it to String" in bytecode.
  • For overriding a generic method with a more specific type, the compiler generates a bridge method so that both the generic and erased signatures exist and dispatch correctly at runtime.

Because the type parameter disappears at runtime, several things are impossible with generics: you cannot write new T(), new T[10], or obj instanceof List<String> (only obj instanceof List<?> is legal), and a class cannot have two overloaded methods that differ only by generic type argument (like void set(List<String> s) and void set(List<Integer> s) in the same class) because after erasure both have the identical signature void set(List).

Common Mistakes

Mistake 1: Trying to Create an Array of a Generic Type

Because of type erasure, the JVM cannot verify at runtime what array element type was intended, so creating a generic array directly is a compile-time error:

class Stack<T> {
    private T[] elements;
    private int size = 0;

    public Stack(int capacity) {
        elements = new T[capacity]; // compile error: generic array creation
    }

    public void push(T item) {
        elements[size++] = item;
    }
}

The fix is to back the structure with an Object[] internally and cast when reading a value out (suppressing the resulting unchecked warning, since you control the invariant yourself):

public class Main {
    static class Stack<T> {
        private Object[] elements;
        private int size = 0;

        public Stack(int capacity) {
            elements = new Object[capacity];
        }

        public void push(T item) {
            elements[size++] = item;
        }

        @SuppressWarnings("unchecked")
        public T pop() {
            T item = (T) elements[--size];
            elements[size] = null;
            return item;
        }
    }

    public static void main(String[] args) {
        Stack<String> stack = new Stack<>(5);
        stack.push("first");
        stack.push("second");
        System.out.println("Popped: " + stack.pop());
        System.out.println("Popped: " + stack.pop());
    }
}

Output:

Popped: second
Popped: first

Mistake 2: Mixing Raw Types With Generics

Using a raw type (a generic class without its type argument, e.g. plain List) disables compile-time checking for that variable and just produces an "unchecked" warning instead of an error, so bad data can sneak in and blow up later, far from where the mistake was made:

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List rawList = new ArrayList();
        rawList.add("a string");
        rawList.add(42);

        List<String> strings = rawList;
        for (String s : strings) {
            System.out.println(s.toUpperCase());
        }
    }
}

Output:

A STRING
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String
	at Main.main(Main.java:10)

The first element prints fine, but the second element (an Integer) blows up when the compiler-inserted cast to String runs, even though nothing in the visible code performs an explicit cast. Avoiding raw types entirely and keeping everything generically typed end-to-end catches this at compile time instead:

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> strings = new ArrayList<>();
        strings.add("a string");
        for (String s : strings) {
            System.out.println(s.toUpperCase());
        }
    }
}

Output:

A STRING

Best Practices

  • Never use raw types (bare List, Box) in new code — always supply a type argument, even if it’s just <Object> or <?>.
  • Follow the PECS mnemonic: "Producer extends, Consumer super" when choosing wildcards for method parameters.
  • Prefer generic methods over casting; let the compiler infer type arguments from the call site whenever possible.
  • Use the diamond operator <> (e.g. new ArrayList<>()) instead of repeating the type argument on both sides of an assignment.
  • Bound type parameters (<T extends Comparable<T>>) when your generic code needs to call methods beyond what Object provides.
  • Don’t overload methods based only on generic type arguments — erasure makes the signatures collide.
  • Reach for @SuppressWarnings("unchecked") sparingly, only on the smallest possible scope, and only when you’ve manually verified the cast is safe.

Practice Exercises

  • Write a generic class Pair<K, V> with a constructor taking a key and a value, and getter methods for each. Create a Pair<String, Integer> mapping a name to an age and print both fields.
  • Write a generic method <T> void printArray(T[] array) that prints every element of any array on its own line, then call it with a String[] and an Integer[].
  • Write a method static void addNumbers(List<? super Integer> list) that adds the integers 1, 2, and 3 into the list. Call it once with a List<Integer> and once with a List<Number>, and print each list afterward to confirm both compile and work.

Summary

  • Generics let a single class or method work across many types while the compiler enforces type safety, removing the need for manual casts.
  • Java implements generics via type erasure: type parameters exist only at compile time and are erased to Object (or their bound) in the compiled bytecode.
  • Bounded type parameters (<T extends X>) let generic code call methods declared on the bound.
  • Wildcards (?, ? extends X, ? super X) make APIs flexible when the exact type parameter doesn’t matter, following the PECS rule.
  • Because of erasure, you cannot create generic arrays, use instanceof with a parameterized type, or overload purely on type arguments.
  • Avoid raw types — they silently disable the type checking generics exist to provide, deferring errors to runtime.