Skip to content
elephantoo

Generics

Lesson 24 of 43 17 min read

Generic classes and methods, type parameters, bounded types, wildcards and type erasure.


Imagine writing a Box class that can hold a book, then another for a phone, then another for a shoe. Generics let you write the class once, with a placeholder type, and the compiler then checks every use. They are why List<String> only accepts strings and why map.get(key) returns the right type without a cast.

Life before generics#

Before Java 5, collections held plain Objects:

Java
List names = new ArrayList();      // a "raw" list
names.add("Asha");
names.add(42);                     // nothing stops this
String s = (String) names.get(1);  // compiles, then ClassCastException at runtime!

With generics, the same mistake is caught by the compiler:

Java
List<String> names = new ArrayList<>();
names.add("Asha");
// names.add(42);                  // compile error: incompatible types: int cannot be converted to String
String s = names.get(0);           // no cast needed

The <> on the right is the diamond operator: the compiler infers <String> from the left side.

Writing a generic class#

Declare one or more type parameters in angle brackets after the class name. By convention they are single capital letters: T (type), E (element), K/V (key/value), R (result).

BoxDemo.java
public class BoxDemo {
    static class Box<T> {
        private T value;

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

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

        boolean isEmpty() { return value == null; }

        @Override
        public String toString() { return "Box[" + value + "]"; }
    }

    public static void main(String[] args) {
        Box<String> word = new Box<>("hello");
        Box<Integer> number = new Box<>(42);

        String w = word.get();          // no casts
        int n = number.get() + 1;       // auto-unboxing works too
        // word.set(5);                 // compile error

        System.out.println(w.toUpperCase() + " " + n);
        System.out.println(word + " " + number);
    }
}
Output
HELLO 43
Box[hello] Box[42]

Each use picks a concrete type argument: Box<String>, Box<Integer>. Type arguments must be reference types, so use Box<Integer>, never Box<int>.

Multiple type parameters#

PairDemo.java
public class PairDemo {
    record Pair<A, B>(A first, B second) {
        <C> Pair<A, C> withSecond(C newSecond) {
            return new Pair<>(first, newSecond);
        }
    }

    static Pair<Integer, Integer> minMax(int[] values) {
        int min = values[0], max = values[0];
        for (int v : values) {
            min = Math.min(min, v);
            max = Math.max(max, v);
        }
        return new Pair<>(min, max);
    }

    public static void main(String[] args) {
        Pair<String, Integer> entry = new Pair<>("apples", 12);
        System.out.println(entry.first() + " -> " + entry.second());

        Pair<Integer, Integer> mm = minMax(new int[]{7, 2, 9, 4});
        System.out.println("min " + mm.first() + ", max " + mm.second());

        System.out.println(entry.withSecond(true));
    }
}
Output
apples -> 12
min 2, max 9
Pair[first=apples, second=true]

Records can be generic too, which makes small type-safe tuples like this very cheap to write.

Generic methods#

A method can declare its own type parameters, placed before the return type. They are often static utilities:

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

public class GenericMethods {
    static <T> T firstOrDefault(List<T> list, T fallback) {
        return list.isEmpty() ? fallback : list.get(0);
    }

    static <T> List<T> repeat(T item, int times) {
        List<T> result = new ArrayList<>();
        for (int i = 0; i < times; i++) result.add(item);
        return result;
    }

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

    public static void main(String[] args) {
        System.out.println(firstOrDefault(List.of("x", "y"), "none"));
        System.out.println(firstOrDefault(List.<String>of(), "none"));
        System.out.println(repeat("ha", 3));

        Integer[] nums = {1, 2, 3};
        swap(nums, 0, 2);
        System.out.println(java.util.Arrays.toString(nums));
    }
}
Output
x
none
[ha, ha, ha]
[3, 2, 1]

The compiler usually infers T from the arguments. You can specify it explicitly when needed: List.<String>of().

Bounded type parameters#

Sometimes the code needs to call methods on T. A bound restricts which types are allowed:

Bounded.java
import java.util.List;

public class Bounded {
    // T must be a Number (or subclass), so we can call doubleValue()
    static <T extends Number> double sum(List<T> values) {
        double total = 0;
        for (T v : values) total += v.doubleValue();
        return total;
    }

    // T must be comparable to itself, so we can call compareTo
    static <T extends Comparable<T>> T max(List<T> items) {
        T best = items.get(0);
        for (T item : items) {
            if (item.compareTo(best) > 0) best = item;
        }
        return best;
    }

    public static void main(String[] args) {
        System.out.println(sum(List.of(1, 2, 3)));
        System.out.println(sum(List.of(1.5, 2.5)));
        System.out.println(max(List.of("pear", "apple", "zucchini", "fig")));
        System.out.println(max(List.of(4, 11, 7)));
        // sum(List.of("a"));   // compile error: String is not a Number
    }
}
Output
6.0
4.0
zucchini
11

extends in a bound covers both classes and interfaces. Multiple bounds use &: <T extends Number & Comparable<T>>.

Generics and inheritance: the surprise#

Integer is a subtype of Number, but List<Integer> is NOT a subtype of List<Number>:

Java
List<Integer> ints = new ArrayList<>();
// List<Number> nums = ints;   // compile error!
// If it were allowed: nums.add(3.14) would put a Double into a list of Integers.

Generic types are invariant. To write methods that accept lists of related types, use wildcards.

Wildcards: ?, ? extends, ? super#

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

public class Wildcards {
    // Accepts List<Integer>, List<Double>, List<Number>... READ as Number
    static double total(List<? extends Number> numbers) {
        double t = 0;
        for (Number n : numbers) t += n.doubleValue();
        // numbers.add(1);   // compile error: we don't know the exact element type
        return t;
    }

    // Accepts List<Integer>, List<Number>, List<Object>... WRITE Integers into it
    static void addOneToFive(List<? super Integer> target) {
        for (int i = 1; i <= 5; i++) target.add(i);
    }

    // Unbounded: any list at all, when you only need Object methods
    static int countNulls(List<?> list) {
        int c = 0;
        for (Object o : list) if (o == null) c++;
        return c;
    }

    public static void main(String[] args) {
        System.out.println(total(List.of(1, 2, 3)));
        System.out.println(total(List.of(0.5, 0.25)));

        List<Number> numbers = new ArrayList<>();
        addOneToFive(numbers);
        List<Object> objects = new ArrayList<>();
        addOneToFive(objects);
        System.out.println(numbers + " " + objects.size());

        System.out.println(countNulls(java.util.Arrays.asList("a", null, "b", null)));
    }
}
Output
6.0
0.75
[1, 2, 3, 4, 5] 5
2

Remember PECS: Producer Extends, Consumer Super.

  • If a parameter produces values you read, use ? extends T.
  • If it consumes values you write, use ? super T.
  • If both, use exactly T.

The JDK follows this everywhere. For example, Collections.copy(List<? super T> dest, List<? extends T> src).

Type erasure#

Generics are a compile-time feature. After checking types, the compiler erases them: Box<String> and Box<Integer> both become plain Box in bytecode, with T replaced by its bound (Object by default) and casts inserted where needed. This kept Java backward-compatible, but it has consequences:

Java
class Container<T> {
    // T item = new T();                 // error: cannot instantiate a type parameter
    // T[] items = new T[10];            // error: generic array creation
    // if (obj instanceof T) { }         // error: T is unknown at runtime
    // static T shared;                  // error: static members can't use the class's T
}

List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass());   // true: both are just ArrayList

The usual workaround is to pass in what you need, for example a factory:

Erasure.java
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;

public class Erasure {
    static <T> List<T> createMany(Supplier<T> factory, int count) {
        List<T> list = new ArrayList<>();
        for (int i = 0; i < count; i++) list.add(factory.get());
        return list;
    }

    public static void main(String[] args) {
        List<StringBuilder> builders = createMany(StringBuilder::new, 3);
        System.out.println(builders.size() + " builders");
        System.out.println(new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass());
    }
}
Output
3 builders
true

(StringBuilder::new is a constructor reference, explained in the lambdas lesson.)

A generic interface#

Interfaces can be generic too, and that is how most of the Collections framework and java.util.function are built:

Repo.java
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class Repo {
    interface Repository<T, ID> {
        void save(ID id, T item);
        Optional<T> findById(ID id);
        int count();
    }

    static class InMemoryRepository<T, ID> implements Repository<T, ID> {
        private final Map<ID, T> store = new HashMap<>();
        public void save(ID id, T item) { store.put(id, item); }
        public Optional<T> findById(ID id) { return Optional.ofNullable(store.get(id)); }
        public int count() { return store.size(); }
    }

    record User(String name) { }

    public static void main(String[] args) {
        Repository<User, Long> users = new InMemoryRepository<>();
        users.save(1L, new User("Asha"));
        users.save(2L, new User("Ben"));
        System.out.println(users.count());
        System.out.println(users.findById(2L).map(User::name).orElse("?"));
        System.out.println(users.findById(9L).isPresent());
    }
}
Output
2
Ben
false

This is essentially the shape of Spring Data's CrudRepository<T, ID>.

Common mistakes#

  • Using raw types (List instead of List<String>), which gives up all type safety. The compiler warns: "unchecked call".
  • Expecting List<Integer> to be assignable to List<Number>.
  • Trying new T(), T.class or generic arrays.
  • Overusing wildcards in return types. Return concrete types like List<String>; use wildcards in parameters.
  • Using primitives as type arguments (List<int>).

What's next#

Generics power the most important library in Java: the Collections framework. Next we start with List, ArrayList, LinkedList and iterating safely.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What is the main benefit of List<String> over a raw List?

  2. 2.A method parameter is List<? extends Number>. What can you do with it?

  3. 3.Why does new T() not compile inside a generic class?

Finished reading?

Mark this lesson complete to track your progress.