Skip to main content
Java · lesson 23 of 66
Open the Java page →
Java intermediate Lesson 23 of 66

Generics in Java

Deep dive into Java generics — generic methods, bounded type parameters, wildcards (? extends, ? super), type erasure, and the PECS principle.

By SkillByExample · Updated

Generics let you write a class or method once and have the compiler check it for every type it is used with. This lesson covers the essentials — generic classes, generic methods, bounds, wildcards and erasure; Bounded Types and Wildcards goes further. Every example is a single file run with the JDK 21 source launcher (java File.java).

Why generics: raw types fail late

Before Java 5 a List held Object, so the compiler couldn’t stop the wrong type going in.

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

public class RawList {
    @SuppressWarnings({"rawtypes", "unchecked"})
    public static void main(String[] args) {
        List names = new ArrayList();
        names.add("Alice");
        names.add(42);                       // compiles: anything goes
        for (Object o : names) {
            String name = (String) o;
            System.out.println(name.toUpperCase());
        }
    }
}
$ java RawList.java
ALICE
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
	at RawList.main(RawList.java:11)

The bug was adding 42, but the crash happens later, at the cast. With List<String> the same mistake is a compile error on the add line itself, and reads need no cast.

Generic classes

Type parameters go in angle brackets after the class name and can be used for fields, parameters and return types.

public class PairDemo {
    public static void main(String[] args) {
        Pair<String, Integer> nameAge = new Pair<>("Alice", 30);
        String name = nameAge.first();
        int age = nameAge.second();              // auto-unboxed
        System.out.println(name + " is " + age);

        Pair<Integer, String> swapped = nameAge.swap();
        System.out.println(swapped + " first is a " + swapped.first().getClass().getSimpleName());
    }
}

class Pair<A, B> {
    private final A first;
    private final B second;
    Pair(A first, B second) { this.first = first; this.second = second; }
    A first()  { return first; }
    B second() { return second; }
    Pair<B, A> swap() { return new Pair<>(second, first); }
    public String toString() { return "(" + first + ", " + second + ")"; }
}
$ java PairDemo.java
Alice is 30
(30, Alice) first is a Integer

The <> “diamond” lets the compiler infer the type arguments from the variable’s type. swap() returns Pair<B, A>, so the compiler knows swapped.first() is an Integer. Type arguments must be reference types: Pair<String, int> doesn’t compile, which is why Integer is used and auto-unboxing converts it to int.

Generic methods

A generic method declares its own type parameter before the return type; the compiler infers it from the arguments.

import java.util.Arrays;

public class GenericMethods {
    static <T> T firstNonNull(T a, T b) {
        return a != null ? a : b;
    }

    static <T> void swap(T[] arr, int i, int j) {
        T tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }

    public static void main(String[] args) {
        System.out.println(firstNonNull(null, "default"));
        System.out.println(firstNonNull("value", "default"));
        String none = firstNonNull(null, null);
        System.out.println(none);

        Integer[] nums = {1, 2, 3, 4, 5};
        swap(nums, 0, 4);
        System.out.println(Arrays.toString(nums));

        String s = GenericMethods.<String>firstNonNull(null, "explicit");
        System.out.println(s);
    }
}
$ java GenericMethods.java
default
value
null
[5, 2, 3, 4, 1]
explicit

firstNonNull(null, null) returns b, which is null — generics add type checks, not null checks. swap works on Integer[] but not int[], because T can’t be a primitive. GenericMethods.<String>firstNonNull(...) shows the rarely needed explicit form.

Bounded type parameters

<T extends Bound> restricts T and makes the bound’s methods available inside the method.

import java.util.List;

public class Bounds {
    static <T extends Number> double sum(List<T> list) {
        double total = 0;
        for (T n : list) total += n.doubleValue();
        return total;
    }

    static <T extends Comparable<T>> T clamp(T val, T min, T max) {
        if (val.compareTo(min) < 0) return min;
        if (val.compareTo(max) > 0) return max;
        return val;
    }

    public static void main(String[] args) {
        System.out.println(sum(List.of(1, 2, 3)) + " " + sum(List.of(1.5, 2.5)));
        System.out.println(clamp(15, 1, 10) + " " + clamp(5, 1, 10) + " " + clamp(-3, 1, 10));
        System.out.println(clamp("kiwi", "banana", "cherry"));
        System.out.println(sum(List.of("a", "b")));
    }
}
$ java Bounds.java
Bounds.java:20: error: method sum in class Bounds cannot be applied to given types;
        System.out.println(sum(List.of("a", "b")));
                           ^
  required: List<T>
  found:    List<String>
  reason: inference variable E has incompatible bounds
    upper bounds: Number,Object
    lower bounds: String
  where T,E are type-variables:
    T extends Number declared in method <T>sum(List<T>)
    E extends Object declared in method <E>of(E,E)
1 error
error: compilation failed

String isn’t a Number, so the call is rejected before anything runs. Remove the last line and the program prints 6.0 4.0, 10 5 1 and cherry — clamp works for any Comparable, and "kiwi" sorts after "cherry", so it is clamped down to the maximum.

Wildcards in one example

Generic types are invariant: a List<Integer> is not a List<Number>. Wildcards widen what a parameter accepts — ? extends T for lists you read from, ? super T for lists you write to.

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

public class Wildcards {
    static void printAll(List<?> list)               { System.out.println("any list: " + list); }
    static double total(List<? extends Number> nums) { return nums.stream().mapToDouble(Number::doubleValue).sum(); }
    static void fill(List<? super Integer> sink)     { for (int i = 1; i <= 3; i++) sink.add(i); }

    static <T> void copy(List<? super T> dest, List<? extends T> src) {
        for (T item : src) dest.add(item);
    }

    public static void main(String[] args) {
        printAll(List.of("a", "b"));
        System.out.println(total(List.of(1, 2, 3)) + " " + total(List.of(0.5, 0.25)));
        List<Number> numbers = new ArrayList<>();
        fill(numbers);
        copy(numbers, List.of(9.5));
        System.out.println(numbers);
    }
}
$ java Wildcards.java
any list: [a, b]
6.0 0.75
[1, 2, 3, 9.5]

copy reads from src (a producer, extends) and writes into dest (a consumer, super) — the PECS rule. A List<Number> can receive Integers from fill and a Double from copy.

Type erasure

The compiler checks generic types and then erases them; at runtime List<String> is just List. A raw reference can therefore sneak the wrong type in, and the failure appears where the compiler inserted a hidden cast.

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

public class Erasure {
    @SuppressWarnings({"rawtypes", "unchecked"})
    public static void main(String[] args) {
        List<String> strings = new ArrayList<>();
        List raw = strings;              // same object, generics switched off
        raw.add(42);                     // no error: at runtime it's just a List
        System.out.println("size " + strings.size() + ", class " + strings.getClass().getName());
        Object o = strings.get(0);
        System.out.println("as Object: " + o);
        String s = strings.get(0);
    }
}
$ java Erasure.java
size 1, class java.util.ArrayList
as Object: 42
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
	at Erasure.main(Erasure.java:13)

The class prints as plain ArrayList — no <String> exists at runtime. Reading the element as Object works; assigning it to a String fails because the compiler inserted a (String) cast there. This is “heap pollution”, and it only happens when raw types or unchecked casts bypass the compiler — which is why their warnings matter.

Comparable and Comparator

Comparable<T> gives a class its natural order; Comparator<T> builds other orders, and generics keep both type-safe.

import java.util.*;

public class StudentSort {
    record Student(String name, double gpa) implements Comparable<Student> {
        public int compareTo(Student other) { return Double.compare(other.gpa, gpa); }   // highest first
    }

    public static void main(String[] args) {
        List<Student> students = new ArrayList<>(List.of(
            new Student("Carol", 3.9), new Student("Bob", 3.5),
            new Student("Alice", 3.9), new Student("Dan", 3.5)));

        Collections.sort(students);
        System.out.println("natural:    " + names(students));

        students.sort(Comparator.comparingDouble(Student::gpa).reversed().thenComparing(Student::name));
        System.out.println("gpa, name:  " + names(students));

        students.sort(Comparator.comparing(Student::name));
        System.out.println("name:       " + names(students));
    }

    static List<String> names(List<Student> s) { return s.stream().map(Student::name).toList(); }
}
$ java StudentSort.java
natural:    [Carol, Alice, Bob, Dan]
gpa, name:  [Alice, Carol, Bob, Dan]
name:       [Alice, Bob, Carol, Dan]

With ties on GPA, the natural order keeps Carol before Alice: Java’s object sort is stable, so equal elements stay in their original order. thenComparing(Student::name) breaks the tie alphabetically, so Alice moves ahead.

Practice

  1. Write a generic Box<T> with map(Function<T, R>) returning a Box<R>, and turn a Box<String> into a Box<Integer> holding its length.
Solution
import java.util.function.Function;

public class BoxMap {
    public static void main(String[] args) {
        Box<String> word = new Box<>("generics");
        Box<Integer> length = word.map(String::length);
        System.out.println(word + " -> " + length);
    }
}

class Box<T> {
    private final T value;
    Box(T value) { this.value = value; }
    <R> Box<R> map(Function<T, R> f) { return new Box<>(f.apply(value)); }
    public String toString() { return "Box(" + value + ")"; }
}
$ java BoxMap.java
Box(generics) -> Box(8)

map declares its own <R>, separate from the class’s <T>, so one box can produce a box of a different type.

  1. Write <T extends Comparable<T>> T maxOf(List<T> list) that throws IllegalArgumentException for an empty list.
Solution
import java.util.List;

public class MaxOf {
    static <T extends Comparable<T>> T maxOf(List<T> list) {
        if (list.isEmpty()) throw new IllegalArgumentException("empty list");
        T best = list.get(0);
        for (T t : list) if (t.compareTo(best) > 0) best = t;
        return best;
    }

    public static void main(String[] args) {
        System.out.println(maxOf(List.of(4, 9, 2)));
        System.out.println(maxOf(List.of("pear", "apple")));
        System.out.println(maxOf(List.<Integer>of()));
    }
}
$ java MaxOf.java
9
pear
Exception in thread "main" java.lang.IllegalArgumentException: empty list
	at MaxOf.maxOf(MaxOf.java:5)
	at MaxOf.main(MaxOf.java:14)

List.<Integer>of() gives the empty list a type, so the call still type-checks.

  1. Write a TypedList<T> that stores a Class<T> and rejects wrongly typed elements at add time, even when called through a raw reference.
Solution
import java.util.ArrayList;
import java.util.List;

public class TypedListDemo {
    @SuppressWarnings({"rawtypes", "unchecked"})
    public static void main(String[] args) {
        TypedList<String> names = new TypedList<>(String.class);
        names.add("Alice");
        TypedList raw = names;
        raw.add(42);
    }
}

class TypedList<T> {
    private final Class<T> type;
    private final List<T> items = new ArrayList<>();
    TypedList(Class<T> type) { this.type = type; }
    void add(T item) { items.add(type.cast(item)); }
}
$ java TypedListDemo.java
Exception in thread "main" java.lang.ClassCastException: Cannot cast java.lang.Integer to java.lang.String
	at java.base/java.lang.Class.cast(Class.java:4069)
	at TypedList.add(TypedListDemo.java:18)
	at TypedListDemo.main(TypedListDemo.java:10)

type.cast checks at runtime what erasure removed, so the bad element fails on insert instead of on a later read. Collections.checkedList does the same for any list.

  1. Sort words by length, then alphabetically for equal lengths, using Comparator methods only.
Solution
import java.util.*;

public class LengthThenAlpha {
    public static void main(String[] args) {
        List<String> words = new ArrayList<>(List.of("pear", "fig", "apple", "kiwi", "date"));
        words.sort(Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder()));
        System.out.println(words);
    }
}
$ java LengthThenAlpha.java
[fig, date, kiwi, pear, apple]

comparingInt avoids boxing the lengths, and thenComparing only runs when the lengths tie.

Frequently Asked Questions

What is type erasure and why does it matter?
At compile time the compiler checks generic types and then erases them — the bytecode contains no type parameters. List<String> and List<Integer> are both just List at runtime. This means you cannot do: new T(), T.class, instanceof List<String>, or create generic arrays. It's the cost of backward compatibility with pre-generics Java.
What is the PECS principle?
Producer Extends, Consumer Super. If a parameterized type is a producer (you only read from it), use ? extends T. If it is a consumer (you only write to it), use ? super T. If it does both, use the exact type T. Example: Collections.copy(List<? super T> dest, List<? extends T> src).
What is the difference between List<?> and List<Object>?
List<Object> accepts only a List<Object> — you cannot pass a List<String> to it because generics are invariant. List<?> (unbounded wildcard) accepts any List regardless of type parameter — but you can only read from it as Object and cannot add elements (except null).
Can I use generics with primitive types?
No. Type parameters must be reference types. Use the wrapper classes: Integer for int, Double for double, etc. The JVM auto-boxes and unboxes, but this adds overhead. For performance-critical numeric code use primitive streams (IntStream, LongStream, DoubleStream) or specialised libraries.