← Back to the section

Generics are a way to tell the compiler what type of data your class or method works with, before the program even runs. Thanks to this, errors are caught at compile time instead of blowing up at runtime, and manual casts disappear from your code.

Why generics: the problem without them

Imagine a list without generics — each element is stored as Object. You can put anything in, but when reading you must cast the type by hand, and the compiler gives you no help:

List list = new ArrayList();   // a "raw" list, no type
list.add("hello");
list.add(42);                  // the compiler stays silent — but it shouldn't

String s = (String) list.get(1); // compiles, but fails at runtime:
                                  // ClassCastException

There are two problems here. First, a number accidentally ended up in the list and nobody noticed. Second, when reading you have to write (String) — a cast that is easy to get wrong.

Generics remove both problems. Specify the type in angle brackets, and the compiler starts watching over you:

List<String> list = new ArrayList<>(); // strings only
list.add("hello");
// list.add(42);                       // compile error — good!

String s = list.get(0);                // no cast needed

Short formula: generics move type checking from runtime to compile time and get rid of manual casts.

Generic classes

You can make your own class generic too. The type parameter is written in angle brackets after the class name — by convention a single capital letter: T (type), E (element), K/V (key/value).

// A container that holds a single value of any type
class Box<T> {
    private T value;

    public void set(T value) { this.value = value; }
    public T get() { return value; }
}

When using it, we substitute a concrete type, and T inside the class effectively becomes it:

Box<String> textBox = new Box<>();
textBox.set("data");
String text = textBox.get();   // the type is already String, no cast

Box<Integer> numberBox = new Box<>();
numberBox.set(100);

Sometimes you need to restrict which types are allowed. The notation <T extends Number> means "T is a Number or any of its subclasses". Inside the class you then have access to Number's methods:

class Calculator<T extends Number> {
    private final T value;

    Calculator(T value) { this.value = value; }

    double half() {
        return value.doubleValue() / 2; // doubleValue() is available on Number
    }
}

Generic methods

A method can declare its own type parameter, even if the class itself is not generic. The parameter is written before the return type:

class Utils {
    // <T> declares a type parameter for this method only
    static <T> T firstOrNull(List<T> items) {
        return items.isEmpty() ? null : items.get(0);
    }
}

The type is most often inferred automatically from the arguments, so you don't need to state it explicitly:

List<String> names = List.of("Anna", "Boris");
String first = Utils.firstOrNull(names); // T is inferred as String

Wildcards: ? extends and ? super

Often a method needs to accept a list of "something" without being tied to one specific type. For that there is the question mark ? — the wildcard. It has two useful forms.

? extends Type — "this type or its subclass". It is convenient to read from such a collection: we know for sure that every element is at least a Number.

// accepts List<Integer>, List<Double>, List<Number> — any of them
static double sum(List<? extends Number> numbers) {
    double total = 0;
    for (Number n : numbers) {   // reading as Number — safe
        total += n.doubleValue();
    }
    return total;
    // numbers.add(...) is forbidden here: the exact type is unknown
}

? super Type — "this type or its ancestor". It is convenient to write into such a collection: we can safely put in an Integer (or its subclass), because the target holds at least an Integer.

// you can pass List<Integer>, List<Number>, List<Object>
static void addNumbers(List<? super Integer> target) {
    target.add(1);  // adding an Integer is safe
    target.add(2);
}

To remember which one to use when, there's the rule PECS — Producer Extends, Consumer Super: if the collection gives you data (producer) — use extends; if you put data into it (consumer) — use super.

Type erasure

The main feature of generics in Java: they exist only at compile time. After type checking, the compiler "erases" them, and the bytecode is left with plain Object (or the bound from extends). This mechanism is called type erasure.

It was done this way for compatibility with old code written before generics appeared (Java 5). But erasure has practical consequences worth knowing about.

At runtime, List<String> and List<Integer> are the very same type:

List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();

// at runtime both are just ArrayList
System.out.println(strings.getClass() == integers.getClass()); // true

You cannot check the type parameter with instanceof, and you cannot create an array of generics:

// if (obj instanceof List<String>) {}  // compile error
// T[] array = new T[10];               // not allowed — the type is erased

Short formula: generics are a contract for the compiler; at runtime there is no information about the concrete type left.

Generics in collections

Most often you meet generics precisely in collections — they are everywhere there. When you declare a collection, you fix the element type, and all the subsequent code becomes safe and readable:

List<String> names = new ArrayList<>();
Map<String, Integer> ages = new HashMap<>();   // key String, value Integer
Set<Long> ids = new HashSet<>();

ages.put("Anna", 30);
int age = ages.get("Anna");      // the value is already Integer, no cast

The interfaces from the standard library are generic too, and when implementing them you substitute the type you need — for example, Comparable<T>:

record Person(String name, int age) implements Comparable<Person> {
    @Override
    public int compareTo(Person other) {
        return Integer.compare(this.age, other.age);
    }
}

With var the type is still there: the variable gets the full generic type from the right-hand side, you just don't have to write it twice.

var scores = new HashMap<String, Integer>(); // the type is HashMap<String, Integer>

In short

  • Generics move type checking to compile time and remove manual casts ((String)).
  • A generic class declares a type parameter (class Box<T>); a bound is set with <T extends Number>.
  • A generic method declares its own type parameter before the return type; the type is usually inferred automatically.
  • Wildcards: ? extends — for reading (producer), ? super — for writing (consumer); the PECS rule.
  • Type erasure means that at runtime the concrete type is gone: you cannot do instanceof List<String> or new T[].
  • In collections generics are used everywhere — they are the main reason collections are safe.
  • Collections in Java — where generics are used most often.
  • Lambdas and the Stream API — generic types underpin functional interfaces and streams.
  • OOP in Java — inheritance and interfaces, on which bounds and wildcards are built.