Why generics exist

A list stores items, a cache keeps values, a method finds the largest element: such code does not care what type of data it handles. Without generics there are two bad options: write the code once per type, or write it once for Object, the superclass of every class. The second throws away the static typing that lets the compiler find mistakes. Before Java 5 (2004) the collections worked exactly like that, and a List<Object> still does:

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

public class Main {
    public static void main(String[] args) {
        List<Object> readings = new ArrayList<>();
        readings.add(412.5);
        readings.add(415.0);
        readings.add("419.2");          // compiles: a List<Object> accepts anything

        double total = 0;
        for (Object item : readings) {
            total += (Double) item;     // a cast is needed to get a number back
            System.out.println("Running total: " + total);
        }
    }
}
Running total: 412.5
Running total: 827.5
Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Double (java.lang.String and java.lang.Double are in module java.base of loader 'bootstrap')

The mistake shows up only when the program runs, and only when the bad element is reached — perhaps months later, in production. Generics move the check to the compiler: a class, interface, record or method declares a placeholder for a type, and the code that uses it says which type it wants. With List<Double>, the same mistake does not compile:

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

public class Main {
    public static void main(String[] args) {
        List<Double> readings = new ArrayList<>();
        readings.add(412.5);
        readings.add("419.2");
    }
}
Main.java:8: error: no suitable method found for add(String)

The full message continues with the reason: "argument mismatch; String cannot be converted to Double". (Plain javac shortens this to "incompatible types: String cannot be converted to Double".) As a bonus, getting an element out of a List<Double> needs no cast.

Generic classes, records and interfaces

A generic class declares its type parameters in angle brackets after its name. Inside the class, T is used like any other type. Code that uses the class supplies a type argument: in Batch<String>, String replaces T everywhere.

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

public class Main {
    public static void main(String[] args) {
        Batch<String> tickets = new Batch<>("NOC");
        tickets.add("INC-10231");
        tickets.add("INC-10232");

        Batch<Integer> readings = new Batch<>("E-001");
        readings.add(412);
        readings.add(415);

        String first = tickets.first();        // no cast needed
        int total = readings.first() + readings.last();
        System.out.println(tickets + " first=" + first);
        System.out.println(readings + " sum of first and last=" + total);
    }
}

class Batch<T> {
    private final String label;
    private final List<T> items = new ArrayList<>();

    Batch(String label) {
        this.label = label;
    }

    void add(T item) {
        items.add(item);
    }

    T first() {
        return items.getFirst();
    }

    T last() {
        return items.getLast();
    }

    @Override
    public String toString() {
        return label + " " + items;
    }
}
NOC [INC-10231, INC-10232] first=INC-10231
E-001 [412, 415] sum of first and last=827

By convention type parameters are single capital letters: T for "type", E for "element" (the JDK declares interface List<E>), K and V for the keys and values of a map (Map<K, V>). Records and interfaces can be generic too, and type arguments can themselves be generic:

import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public class Main {
    public static void main(String[] args) {
        Pair<String, Integer> reading = new Pair<>("E-001", 412);
        System.out.println(reading);
        System.out.println(reading.first().length() + reading.second());

        Map<String, List<Integer>> perMeter = new TreeMap<>();
        perMeter.put("E-002", List.of(388, 391));
        perMeter.put("E-001", List.of(412, 415));
        System.out.println(perMeter);
    }
}

record Pair<A, B>(A first, B second) { }
Pair[first=E-001, second=412]
417
{E-001=[412, 415], E-002=[388, 391]}

reading.first() is known to be a String, so length() compiles (6 + 412 = 417). Records are explained in Records, Enums and Sealed Types; the generic interfaces you meet most are Comparable<T>, Comparator<T> and the collection interfaces of Arrays and Collections.

The diamond operator

Writing the type arguments twice — Map<String, List<Integer>> m = new TreeMap<String, List<Integer>>() — is tedious. The diamond operator <> (Java 7) lets the compiler infer them from the context: new TreeMap<>(). Be careful when you combine it with var, because then there is no context to infer from:

import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        var anything = new ArrayList<>();
        anything.add(412);
        anything.add("INC-10231");
        System.out.println(anything);

        var readings = new ArrayList<Integer>();
        readings.add(412);
        System.out.println(readings);
    }
}
[412, INC-10231]
[412]

var anything = new ArrayList<>() becomes an ArrayList<Object> and accepts anything — the very problem generics were meant to solve. With var, write the type argument on the right; otherwise, declare the type on the left and use the diamond on the right.

Generic methods

A method can have its own type parameters, written before the return type: static <T> T lastOf(List<T> items). Each call may use a different type, and usually you do not write it: the compiler infers T from the arguments and from the variable the result is assigned to. When inference needs help, put the type argument before the method name, as in Main.<Integer>repeat(0, 2).

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

public class Main {
    public static void main(String[] args) {
        List<String> tickets = List.of("INC-10231", "INC-10232", "INC-10233");
        List<Double> temperatures = List.of(17.5, 18.25, 16.0);

        String lastTicket = lastOf(tickets);
        double lastTemperature = lastOf(temperatures);
        System.out.println(lastTicket + " " + lastTemperature);

        List<String> placeholders = repeat("n/a", 3);
        System.out.println(placeholders);
        System.out.println(Main.<Integer>repeat(0, 2));
    }

    static <T> T lastOf(List<T> items) {
        return items.get(items.size() - 1);
    }

    static <T> List<T> repeat(T value, int times) {
        List<T> result = new ArrayList<>();
        for (int i = 0; i < times; i++) {
            result.add(value);
        }
        return result;
    }
}
INC-10233 16.0
[n/a, n/a, n/a]
[0, 0]

Many JDK methods are generic: List.of, Collections.max, Optional.of, Comparator.comparing. That is why List.of("a", "b") returns a List<String> without you saying so.

Bounded type parameters

Inside lastOf, T could be anything, so the method can only use what every object has (toString, equals). To find the largest element, the method needs compareTo. A bounded type parameter restricts which types are allowed — and in return lets the method use the bound's methods. <T extends Comparable<T>> reads "any type T that can be compared with another T". For a bound, extends is used for interfaces as well as classes.

import java.util.List;

public class Main {
    public static void main(String[] args) {
        System.out.println(max(List.of(412, 388, 415)));
        System.out.println(max(List.of("Mei", "Amara", "Sanne")));
        System.out.println(max(List.of(17.5, 18.25, 16.0)));
    }

    static <T extends Comparable<T>> T max(List<T> items) {
        T best = items.getFirst();
        for (T item : items) {
            if (item.compareTo(best) > 0) {
                best = item;
            }
        }
        return best;
    }
}
415
Sanne
18.25

Integer, String and Double all implement Comparable. A record that does not is refused at the call:

import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Meter> meters = List.of(new Meter("E-001"), new Meter("E-002"));
        System.out.println(max(meters));
    }

    static <T extends Comparable<T>> T max(List<T> items) {
        T best = items.getFirst();
        for (T item : items) {
            if (item.compareTo(best) > 0) {
                best = item;
            }
        }
        return best;
    }
}

record Meter(String id) { }
Main.java:6: error: method max in class Main cannot be applied to given types;

The rest of the message says why: "inference variable T has incompatible bounds … upper bounds: Comparable<T>". Generic error messages are long; look for the line with "reason:".

A generic class can be bounded the same way. Because every T below is a Number, the class may call doubleValue():

import java.util.List;

public class Main {
    public static void main(String[] args) {
        Statistics<Integer> kwh = new Statistics<>(List.of(412, 415, 419));
        Statistics<Double> celsius = new Statistics<>(List.of(17.5, 18.25, 16.0));
        System.out.println(kwh.average());
        System.out.println(celsius.average());
    }
}

class Statistics<T extends Number> {
    private final List<T> values;

    Statistics(List<T> values) {
        this.values = values;
    }

    double average() {
        double sum = 0;
        for (T value : values) {
            sum += value.doubleValue();     // allowed: every T is a Number
        }
        return sum / values.size();
    }
}
415.3333333333333
17.25

Several bounds are joined with &, a class bound (if any) first: <T extends Number & Comparable<T>>.

Wildcards

Integer is a subclass of Number, so it is tempting to think a List<Integer> is a List<Number>. It is not, and the compiler says so:

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

public class Main {
    public static void main(String[] args) {
        List<Integer> readings = new ArrayList<>(List.of(412, 415));
        List<Number> numbers = readings;
    }
}
Main.java:7: error: incompatible types: List<Integer> cannot be converted to List<Number>

If it were allowed, numbers.add(17.5) would put a Double into a list of integers. Generic types are therefore invariant: List<A> and List<B> are unrelated types, whatever the relation between A and B. (Arrays are not: a String[] is an Object[], and the same mistake is caught only at run time with an ArrayStoreException.)

A wildcard type written with ? solves this for parameters that should accept a family of generic types. List<? extends Number> means "a list of some type that is Number or a subclass of it":

List of Integer, List of Double and List of Number are all subtypes of List of question mark extends Number, but none of them is a subtype of another: a List of Integer is not a List of Number. List<? extends Number> List<Integer> List<Double> List<Number> is a subtype of no arrows between the lower boxes: generic types are invariant
The wildcard type is the common supertype of all three lists; the lists themselves are unrelated.
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Integer> kwh = List.of(412, 415, 419);
        List<Double> celsius = List.of(17.5, 18.25, 16.0);
        System.out.println(total(kwh));
        System.out.println(total(celsius));
    }

    static double total(List<? extends Number> values) {
        double sum = 0;
        for (Number value : values) {
            sum += value.doubleValue();
        }
        return sum;
    }
}
1246.0
51.75

The price: total may read elements as Number, but it may not add anything to values (except null), because it does not know which subtype the list really holds. For the opposite situation — a method that puts integers into a list — use a lower bound, ? super Integer: "a list of Integer or of a superclass of it".

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

public class Main {
    public static void main(String[] args) {
        List<Integer> readings = new ArrayList<>();
        List<Number> mixed = new ArrayList<>(List.of(17.5));
        List<Object> anything = new ArrayList<>(List.of("E-001"));

        addMissingDays(readings, 2);
        addMissingDays(mixed, 2);
        addMissingDays(anything, 2);
        System.out.println(readings + " " + mixed + " " + anything);
    }

    static void addMissingDays(List<? super Integer> target, int days) {
        for (int i = 0; i < days; i++) {
            target.add(0);
        }
    }
}
[0, 0] [17.5, 0, 0] [E-001, 0, 0]
Form Accepts Inside the method
List<?> A list of any type Read elements as Object; add nothing
List<? extends Number> List<Number>, List<Integer>, List<Double>, … Read as Number; add nothing
List<? super Integer> List<Integer>, List<Number>, List<Object> Add Integers; read only as Object

A common memory aid, from Joshua Bloch's book Effective Java, is PECS: "producer extends, consumer super". A parameter the method reads from (it produces values) gets extends; one it writes to (it consumes values) gets super. That is why the JDK's Collections.max is declared with Comparable<? super T>: it also works for types such as LocalDate, which is comparable to its supertype ChronoLocalDate rather than to LocalDate itself.

Type erasure

Generics were added to Java 5 in a way that kept existing compiled code and libraries working. The compiler checks all type arguments, then removes them: in the class file, List<String> and List<Integer> are both plain List, a type parameter becomes its bound (or Object), and the compiler inserts the casts you no longer write. This is called type erasure. At run time, the JVM cannot tell the two lists apart:

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

public class Main {
    public static void main(String[] args) {
        List<String> tickets = new ArrayList<>();
        List<Integer> readings = new ArrayList<>();

        System.out.println(tickets.getClass().getName());
        System.out.println(readings.getClass().getName());
        System.out.println(tickets.getClass() == readings.getClass());
    }
}
java.util.ArrayList
java.util.ArrayList
true

What erasure rules out

Anything that would need the type argument at run time does not compile:

  • new T() ("unexpected type … found: type parameter T") and new T[10] ("generic array creation") — the JVM would not know which class to create.
  • obj instanceof List<String> when obj is an Object ("Object cannot be safely cast to List<String>"); obj instanceof List<?> is allowed.
  • Two overloads that differ only in their type arguments:
import java.util.List;

public class Main {
    static void print(List<String> tickets) { }

    static void print(List<Integer> readings) { }

    public static void main(String[] args) { }
}
Main.java:6: error: name clash: print(List<Integer>) and print(List<String>) have the same erasure

When generic code really needs the type at run time, the usual solution is to pass it in as a Class<T> object, often called a type token. String.class is a Class<String>, so the compiler infers T from it:

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Object> cells = List.of("E-001", 412, new BigDecimal("0.32"), "INC-10231");

        List<String> texts = onlyOfType(cells, String.class);
        List<Integer> numbers = onlyOfType(cells, Integer.class);
        System.out.println(texts + " " + numbers);
    }

    static <T> List<T> onlyOfType(List<Object> values, Class<T> type) {
        List<T> result = new ArrayList<>();
        for (Object value : values) {
            if (type.isInstance(value)) {
                result.add(type.cast(value));
            }
        }
        return result;
    }
}
[E-001, INC-10231] [412]

JSON libraries use the same trick: readValue(json, MeterReading.class). For a type such as List<MeterReading>, which has no class object of its own, they offer a "type reference" class — see Files, I/O and JSON.

Generics and primitive types

Because a type parameter is erased to Object (or its bound), a type argument must be a reference type. The eight primitive types are not allowed:

import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<int> readings = List.of(412, 415);
    }
}
Main.java:5: error: unexpected type

Use the wrapper class instead: List<Integer>. Java converts between int and Integer automatically (autoboxing, explained in Variables, Types and Operators), so readings.add(412) just works. The cost is up to one object per number. For large amounts of numeric data, plain arrays (int[]) and the primitive streams (IntStream, see Lambdas, Functional Interfaces and Streams) avoid it. Making generics work with primitives without that cost is a long-term goal of the OpenJDK project Valhalla; its first part, value objects (JEP 401), has been integrated into JDK 28 as a preview feature; JDK 28 is still in development as of October 2026.

Raw types

A raw type is a generic type used without type arguments: List instead of List<String>. It exists so that code written before Java 5 still compiles, and it switches off the type checks for that variable. Old tutorials and legacy code are full of it. Assigning a typed list to a raw variable lets anything in:

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

public class Readings {
    public static void main(String[] args) {
        List<Integer> readings = new ArrayList<>();
        List raw = readings;                // raw type
        raw.add("INC-10231");               // no error: the check is off
        System.out.println(raw);
        int first = readings.get(0);        // line 10
    }
}
Note: Readings.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
[INC-10231]
Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer (java.lang.String and java.lang.Integer are in module java.base of loader 'bootstrap')
	at Readings.main(Readings.java:10)

The program fails on line 10, which contains no visible cast: the cast was inserted by the compiler (erasure again), and the string that broke it was added two lines earlier. A list that holds elements of the wrong type like this is called heap pollution. javac only prints a short note; with -Xlint:all it names each problem:

Readings.java:7: warning: [rawtypes] found raw type: List
        List raw = readings;                // raw type
        ^
  missing type arguments for generic class List<E>
  where E is a type-variable:
    E extends Object declared in interface List
Readings.java:8: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
        raw.add("INC-10231");               // no error: the check is off
               ^
  where E is a type-variable:
    E extends Object declared in interface List
2 warnings