← Back to the section

JAVA FUNDAMENTALS Phase 2. Generics

Generics in Java: type safety without casting

Java

Generic classes and methods, wildcards, extends, super and type erasure.

1. Generics move type errors into compilation

1 / 5

List<String> tells the compiler that only strings belong inside. Invalid values are rejected, and reads need no manual (String) cast.

  • Angle brackets describe the element type.
  • Errors appear before startup.
  • Primitives use wrappers such as List<Integer>.
List<String> add("Java") add(42) acceptedcompiler error

2. A generic class takes the type as a parameter

2 / 5

class Box<T> uses T for its field and methods. A Box<String> treats every T as String, while Box<Integer> uses Integer.

  • One implementation supports many types.
  • T extends Number restricts operations to numbers.
  • Each variable preserves its own type argument.
Box<T> Box<String>value - String Box<Integer>value - Integer T extends Number - numbers only

3. A generic method infers the type from arguments

3 / 5

<T> T first(List<T> values) returns the same type as the input list. The compiler usually infers T from the arguments.

  • The method's type parameter precedes the return type.
  • Input and result types stay consistent.
  • Explicit arguments are needed only when inference is insufficient.
List<String> first String <T> T first(List<T> values)

4. extends reads subtypes, super accepts parents

4 / 5

? extends Number reads Number but rejects additions. ? super Integer accepts Integer and returns Object on reads.

  • Producer extends, consumer super - PECS.
  • List<Integer> is not a List<Number>.
  • Wildcards restrict operations to protect the contained type.
? extends Number ? super Integer get - Numberadd(Integer) add - forbidden get - Object

5. Type parameters are erased after compilation

5 / 5

List<String> and List<Integer> become plain List in bytecode. Checks belong to the compiler, not the runtime.

  • You cannot create new T() or T[].
  • instanceof checks only the raw type.
  • Creation requires Class<T> or a factory.
List<String> List<Integer> List instanceof List - yes · new T() - no

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.

source code compiler runtime List<String> listlist.add("hello")list.add(42)String s = list.get(0) <String> what may go in "hello" — ok 42 — error ArrayList <String> "hello"elements are Objectno brackets left get() returned String: the compiler added the cast

Angle brackets live until compilation ends: by them the compiler rejects list.add(42), and it inserts the cast on reading itself. The bytecode keeps a plain ArrayList, and the elements in it are Object.

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:

live example

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

public class RawList {
    public static void main(String[] args) {
        List list = new ArrayList();   // a "raw" list, no type
        list.add("hello");
        list.add(42);                  // the compiler stays silent — but it shouldn't

        try {
            String s = (String) list.get(1);  // we expect a string, there is a number
            System.out.println(s);
        } catch (ClassCastException e) {
            System.out.println("failed: " + e.getMessage());
        }
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

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:

live example

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

public class TypedList {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>(); // strings only
        list.add("hello");
        // list.add(42);                       // compile error — good!

        String s = list.get(0);                // no cast needed
        System.out.println(s.toUpperCase());   // HELLO
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

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). When using it, we substitute a concrete type, and T inside the class effectively becomes it:

live example

public class BoxDemo {
    public static void main(String[] args) {
        Box<String> textBox = new Box<>();
        textBox.set("data");
        String text = textBox.get();               // already String, no cast
        System.out.println(text);

        Box<Integer> numberBox = new Box<>();
        numberBox.set(100);
        System.out.println(numberBox.get() + 1);   // 101 — numbers, not strings
    }
}

// a container that holds a single value of any type
class Box<T> {
    private T value;
    void set(T value) { this.value = value; }
    T get() { return value; }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

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, and at the call site it is most often inferred automatically from the arguments, so you don't need to state it explicitly:

live example

import java.util.List;

public class FirstOrNull {
    public static void main(String[] args) {
        List<String> names = List.of("Anna", "Boris");
        String first = firstOrNull(names);        // T is inferred as String
        System.out.println(first);

        List<String> empty = List.of();
        System.out.println(firstOrNull(empty));   // empty list — null
    }

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

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

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.

live example

import java.util.List;

public class Pecs {
    public static void main(String[] args) {
        System.out.println(sum(List.of(1, 2, 3)));    // List<Integer> — 6.0
        System.out.println(sum(List.of(0.5, 1.5)));   // List<Double> — 2.0
        List<Number> mixed = List.of(1, 2.5);
        System.out.println(sum(mixed));               // List<Number> — 3.5
    }

    // 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;
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

The method reads a List<Integer>, a List<Double> and a mixed List<Number>: every element is at least a Number to it. Writing into such a list is not allowed, not even an Integer: numbers might be a List<Double>, and an integer would end up among the doubles.

// error: incompatible types: int cannot be converted to CAP#1
import java.util.ArrayList;
import java.util.List;

public class ExtendsWildcardAdd {
    public static void main(String[] args) {
        addOne(new ArrayList<Double>());
    }

    static void addOne(List<? extends Number> numbers) {
        numbers.add(1);
    }
}

? super Type — "this type or its ancestor". This bounds the list itself, not what you put into it: addNumbers below accepts a List<Integer>, a List<Number> or a List<Object>. The method does not know which one it got, so it is convenient to write an Integer into such a collection: it fits into any of the three. A Number, however, cannot be added: if a List<Integer> was passed, a Double could get in through Number.

live example

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

public class Pecs {
    public static void main(String[] args) {
        List<Integer> ints = new ArrayList<>();
        List<Number> numbers = new ArrayList<>(List.of(0.5));
        List<Object> objects = new ArrayList<>(List.of("text"));

        addNumbers(ints);
        addNumbers(numbers);
        addNumbers(objects);

        System.out.println(ints);      // List<Integer> — [1, 2]
        System.out.println(numbers);   // List<Number> — [0.5, 1, 2]
        System.out.println(objects);   // List<Object> — [text, 1, 2]
    }

    // accepts List<Integer>, List<Number>, List<Object> — any of them
    static void addNumbers(List<? super Integer> target) {
        target.add(1);   // adding an Integer — safe
        target.add(2);
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

One method, three lists, and an Integer goes into each of them without any casts. Now try adding a Number: the compiler refuses, because target might be a List<Integer>, and a Double would get in.

// error: incompatible types: Number cannot be converted to CAP#1
import java.util.ArrayList;
import java.util.List;

public class SuperWildcardNumber {
    public static void main(String[] args) {
        addNumber(new ArrayList<Integer>(), 0.5);
    }

    static void addNumber(List<? super Integer> target, Number n) {
        target.add(n);
    }
}

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. The cast has not gone anywhere — it is simply the compiler that inserted it, not you.

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:

live example

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

public class Erasure {
    public static void main(String[] args) {
        List<String> strings = new ArrayList<>();
        List<Integer> integers = new ArrayList<>();

        // at runtime both are just ArrayList
        System.out.println(strings.getClass() == integers.getClass()); // true
        System.out.println(strings.getClass().getName());              // java.util.ArrayList
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

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:

live example

import java.util.HashMap;
import java.util.Map;

public class Ages {
    public static void main(String[] args) {
        Map<String, Integer> ages = new HashMap<>();   // key String, value Integer
        ages.put("Anna", 30);
        ages.put("Boris", 25);

        int age = ages.get("Anna");   // already Integer, no cast
        System.out.println(age + 1);
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

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.