Generics in Java

Series: Java Core for Beginners Lecture: 09 of 12 Topics: Why generics exist · Generic classes & methods · Type parameters · Bounded type parameters · Wildcards · Type erasure


The Problem Generics Solve

Before Java 5 introduced generics, the Collections Framework existed but worked with raw Object references. Every container held Object, and you had to cast elements back to their actual type when retrieving them:

// Pre-generics Java (Java 1.4 and earlier) — do not write this today
List names = new ArrayList();
names.add("Alice");
names.add("Bob");
names.add(42);            // adding an integer to a "names" list — no complaint from compiler

String first = (String) names.get(0);   // explicit cast required
String second = (String) names.get(2);  // ClassCastException at runtime! 42 is not a String

Two problems are obvious:

  1. No type safety at compile time. The compiler had no way to enforce that a list of names should only contain strings. Bugs were discovered at runtime, not at compile time.
  2. Casting noise everywhere. Every retrieval required a cast, cluttering the code and adding runtime overhead.

Generics solve both problems by letting you parameterize types — telling the compiler exactly what type a container holds, so it can enforce correctness at compile time and eliminate casts:

// With generics
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
names.add(42);   // COMPILE ERROR — int is not a String

String first = names.get(0);   // no cast needed — compiler knows it's a String

Generics are pervasive in modern Java. Every time you write List, Map, or Optional, you are using generics. Understanding how they work — and why they are designed the way they are — makes you a significantly more effective Java programmer.


Generic Classes

A generic class is parameterized by one or more type parameters declared in angle brackets after the class name. The type parameter is a placeholder that callers replace with a concrete type when they use the class.

Defining a generic class

Let us build a reusable Pair class that holds two values of potentially different types:

public class Pair<A, B> {

    private final A first;
    private final B second;

    public Pair(A first, B second) {
        this.first  = first;
        this.second = second;
    }

    public A getFirst()  { return first; }
    public B getSecond() { return second; }

    public Pair<B, A> swap() {
        return new Pair<>(second, first);
    }

    @Override
    public String toString() {
        return "(" + first + ", " + second + ")";
    }
}

Using the class:

Pair<String, Integer> nameAge = new Pair<>("Alice", 30);
System.out.println(nameAge.getFirst());    // "Alice"
System.out.println(nameAge.getSecond());   // 30
System.out.println(nameAge);              // (Alice, 30)

Pair<Integer, String> swapped = nameAge.swap();
System.out.println(swapped);              // (30, Alice)

Pair<String, String> coords = new Pair<>("40.7128° N", "74.0060° W");
System.out.println(coords);               // (40.7128° N, 74.0060° W)

Notice that the same Pair class works for any combination of types. Without generics you would need a separate class for every combination, or fall back to Object with all its problems.

A generic stack

Let us build a more realistic example — a generic stack (LIFO — last in, first out):

public class Stack<T> {

    private final List<T> elements = new ArrayList<>();

    public void push(T item) {
        elements.add(item);
    }

    public T pop() {
        if (isEmpty()) {
            throw new NoSuchElementException("Stack is empty.");
        }
        return elements.remove(elements.size() - 1);
    }

    public T peek() {
        if (isEmpty()) {
            throw new NoSuchElementException("Stack is empty.");
        }
        return elements.get(elements.size() - 1);
    }

    public boolean isEmpty() {
        return elements.isEmpty();
    }

    public int size() {
        return elements.size();
    }

    @Override
    public String toString() {
        return elements.toString();
    }
}

Using the generic stack:

Stack<Integer> intStack = new Stack<>();
intStack.push(10);
intStack.push(20);
intStack.push(30);
System.out.println(intStack);       // [10, 20, 30]
System.out.println(intStack.pop()); // 30
System.out.println(intStack.peek()); // 20
System.out.println(intStack.size()); // 2

Stack<String> strStack = new Stack<>();
strStack.push("hello");
strStack.push("world");
System.out.println(strStack.pop()); // "world"

The same Stack class works for integers, strings, custom objects — any type. The compiler guarantees that a Stack only ever holds integers and a Stack only ever holds strings.

The diamond operator

Since Java 7, the compiler can infer the type argument on the right side of an assignment — you only need to write <> (the diamond operator):

// Full form — redundant
Pair<String, Integer> p = new Pair<String, Integer>("Alice", 30);

// With diamond operator — type is inferred from the left side
Pair<String, Integer> p = new Pair<>("Alice", 30);

// Also inferred in variable declarations (Java 10+ with var)
var p = new Pair<>("Alice", 30);   // inferred as Pair<String, Integer>

Always use the diamond operator. Writing the type twice is redundant and adds noise.

Generics with primitives — autoboxing

Type parameters can only be reference types — not primitives. You cannot write Stack. Instead, use the corresponding wrapper class and rely on autoboxing:

Stack<Integer> intStack = new Stack<>();
intStack.push(42);          // autoboxing: int 42 → Integer.valueOf(42)
int value = intStack.pop(); // unboxing: Integer → int

Autoboxing and unboxing happen automatically — you rarely notice them. Be aware they exist for performance-sensitive code, since each boxing operation allocates a new wrapper object.


Generic Methods

A generic method has its own type parameters, independent of the class it belongs to. This lets you write type-safe utility methods without making the entire enclosing class generic.

Defining a generic method

The type parameter list goes before the return type:

public class ArrayUtils {

    // Generic method — T is the element type
    public static <T> void swap(T[] array, int i, int j) {
        T temp   = array[i];
        array[i] = array[j];
        array[j] = temp;
    }

    // Generic method returning a value
    public static <T> T lastElement(T[] array) {
        if (array == null || array.length == 0) {
            throw new NoSuchElementException("Array is empty.");
        }
        return array[array.length - 1];
    }

    // Generic method creating a List from varargs
    public static <T> List<T> listOf(T... elements) {
        List<T> result = new ArrayList<>();
        for (T element : elements) {
            result.add(element);
        }
        return result;
    }
}

Using generic methods:

String[] names = {"Alice", "Bob", "Charlie"};
ArrayUtils.swap(names, 0, 2);
System.out.println(Arrays.toString(names));   // [Charlie, Bob, Alice]

Integer[] numbers = {10, 20, 30, 40};
System.out.println(ArrayUtils.lastElement(numbers));   // 40

List<String> list = ArrayUtils.listOf("x", "y", "z");
System.out.println(list);   // [x, y, z]

Type inference for generic methods

The compiler infers the type parameter from the arguments passed. You rarely need to specify it explicitly:

// Explicit type — verbose, usually unnecessary
ArrayUtils.<String>swap(names, 0, 2);

// Inferred — cleaner
ArrayUtils.swap(names, 0, 2);

You only need explicit type specification in rare cases where the compiler cannot infer the type, such as when there are no method arguments to infer from.

A practical generic utility method

public class CollectionUtils {

    // Returns the first element matching a predicate, or null if none matches
    public static <T> T findFirst(List<T> list, java.util.function.Predicate<T> predicate) {
        for (T element : list) {
            if (predicate.test(element)) {
                return element;
            }
        }
        return null;
    }

    // Returns all elements matching a predicate
    public static <T> List<T> filter(List<T> list, java.util.function.Predicate<T> predicate) {
        List<T> result = new ArrayList<>();
        for (T element : list) {
            if (predicate.test(element)) {
                result.add(element);
            }
        }
        return result;
    }

    // Transforms all elements using a mapping function
    public static <T, R> List<R> map(List<T> list, java.util.function.Function<T, R> mapper) {
        List<R> result = new ArrayList<>();
        for (T element : list) {
            result.add(mapper.apply(element));
        }
        return result;
    }
}
List<String> names = List.of("Alice", "Bob", "Alexander", "Barbara", "Charlie");

String first = CollectionUtils.findFirst(names, n -> n.startsWith("A"));
System.out.println(first);   // "Alice"

List<String> aNames = CollectionUtils.filter(names, n -> n.startsWith("A"));
System.out.println(aNames);  // [Alice, Alexander]

List<Integer> lengths = CollectionUtils.map(names, String::length);
System.out.println(lengths); // [5, 3, 9, 7, 7]

This is precisely how the Stream API (Lecture 12) works internally — filter, map, and similar operations are generic methods built on the same foundations.


Type Parameters and Naming Conventions

By convention, type parameter names are single uppercase letters. The most common are:

Name Conventional use
T Type — the generic primary type parameter
E Element — used by collections (List)
K Key — used by maps (Map)
V Value — used by maps (Map)
N Number — when the type must be numeric
R Return type — used in function interfaces
A, B When you need two type parameters of similar roles

These are conventions, not keywords — you could write List and it would compile. But following the conventions makes your code immediately readable to any Java developer.

For multiple parameters, separate with commas: Map, Function, BiFunction.


Bounded Type Parameters

Sometimes you want to accept any type, but only types that satisfy a certain constraint — for example, types that implement Comparable, or types that extend a specific class. Bounded type parameters express these constraints.

Upper bounded — extends

means T must be SomeType or any subclass/implementation of it:

// Only accepts types that implement Comparable — i.e., types that can be compared
public static <T extends Comparable<T>> T max(T a, T b) {
    return a.compareTo(b) >= 0 ? a : b;
}
System.out.println(max(10, 20));           // 20 — Integer implements Comparable
System.out.println(max("apple", "banana")); // "banana" — String implements Comparable
System.out.println(max(3.14, 2.71));       // 3.14 — Double implements Comparable

Without the bound, calling a.compareTo(b) would be a compile error because a plain T has no guarantee of having a compareTo method — it could be any type.

Multiple bounds

A type parameter can have multiple bounds separated by &:

// T must extend Animal AND implement Flyable
public static <T extends Animal & Flyable> void makeFlightSound(T creature) {
    creature.makeSound();   // from Animal
    creature.fly();         // from Flyable
}

With multiple bounds, at most one can be a class; the rest must be interfaces. The class must come first.

A generic min/max method with bounds

public static <T extends Comparable<T>> T min(List<T> list) {
    if (list == null || list.isEmpty()) {
        throw new IllegalArgumentException("List must not be empty.");
    }
    T minimum = list.get(0);
    for (T element : list) {
        if (element.compareTo(minimum) < 0) {
            minimum = element;
        }
    }
    return minimum;
}
System.out.println(min(List.of(5, 3, 8, 1, 9)));           // 1
System.out.println(min(List.of("zebra", "ant", "mouse")));  // "ant"

Why extends is used for both classes and interfaces in bounds

In the context of type bounds, extends means “is a subtype of”, covering both:

  • Subclasses: — T must be Animal or any subclass
  • Implementations: > — T must implement Comparable

This overloading of extends is a quirk of Java’s generic syntax. In type bounds, you always write extends, never implements.


Wildcards

Sometimes you want to write a method that accepts a collection of some type, but you do not need to know the exact type — you just need to read from it, or write to it. Wildcards (?) express “some unknown type.”

Unbounded wildcard —

means “a collection of some unknown type.” Use it when you only need to call methods defined on Object:

public static void printAll(List<?> list) {
    for (Object element : list) {
        System.out.println(element);
    }
}
printAll(List.of(1, 2, 3));           // works — List<Integer>
printAll(List.of("a", "b", "c"));     // works — List<String>
printAll(List.of(3.14, 2.71));        // works — List<Double>

You cannot add elements to a List (except null) because the compiler does not know the actual type — it cannot verify that whatever you add is safe.

Why List is NOT the same as List

This is one of the most important and counterintuitive facts about Java generics. You might think that since String is a subtype of Object, List should be a subtype of List. It is not. Generics are invariant:

List<String>  strings = new ArrayList<>();
List<Object>  objects = strings;   // COMPILE ERROR — List<String> is not a List<Object>

Why? If this were allowed:

List<String>  strings = new ArrayList<>();
List<Object>  objects = strings;       // hypothetically allowed
objects.add(42);                       // adds an Integer to what is actually a List<String>
String s = strings.get(0);            // ClassCastException at runtime!

Invariance prevents this class of bugs. List is the correct type when you want to accept any list regardless of element type:

List<String>  strings = new ArrayList<>();
List<?>       any     = strings;       // fine — wildcard accepts any List
any.add("hello");                      // COMPILE ERROR — cannot add to List<?>
Object o = any.get(0);                 // fine — returns Object

Upper bounded wildcard —

means “a collection of T or any subtype of T.” Use it when you need to read from a collection:

// Accepts List<Number>, List<Integer>, List<Double>, List<Long>, etc.
public static double sumList(List<? extends Number> numbers) {
    double total = 0;
    for (Number n : numbers) {
        total += n.doubleValue();   // Number.doubleValue() works for all subtypes
    }
    return total;
}
List<Integer> ints    = List.of(1, 2, 3, 4, 5);
List<Double>  doubles = List.of(1.1, 2.2, 3.3);
List<Long>    longs   = List.of(100L, 200L, 300L);

System.out.println(sumList(ints));    // 15.0
System.out.println(sumList(doubles)); // 6.6000000000000005
System.out.println(sumList(longs));   // 600.0

You cannot add elements to a List because the compiler does not know the actual subtype — adding an Integer to what might actually be a List would be unsafe.

Lower bounded wildcard —

means “a collection of T or any supertype of T.” Use it when you need to write to a collection:

// Accepts List<Integer>, List<Number>, List<Object>
public static void addNumbers(List<? super Integer> list, int count) {
    for (int i = 1; i <= count; i++) {
        list.add(i);   // safe — we know the list can hold at least Integer
    }
}
List<Integer> ints    = new ArrayList<>();
List<Number>  numbers = new ArrayList<>();
List<Object>  objects = new ArrayList<>();

addNumbers(ints,    5);   // List<Integer> — works
addNumbers(numbers, 5);   // List<Number>  — works (Integer is a Number)
addNumbers(objects, 5);   // List<Object>  — works (Integer is an Object)
// addNumbers(List.of("a", "b"), 5);   // COMPILE ERROR — String is not a supertype of Integer

The PECS mnemonic

A well-known rule for remembering when to use each wildcard:

PECS — Producer Extends, Consumer Super

  • If a collection produces values you read from it → use
  • If a collection consumes values you write to it → use
  • If you both read and write → use the exact type
// source produces values (you read from it) → extends
// destination consumes values (you write to it) → super
public static <T> void copy(List<? extends T> source, List<? super T> destination) {
    for (T item : source) {
        destination.add(item);
    }
}
List<Integer> source      = List.of(1, 2, 3, 4, 5);
List<Number>  destination = new ArrayList<>();

copy(source, destination);
System.out.println(destination);   // [1, 2, 3, 4, 5]

This copy method works for any combination where the source element type is a subtype of the destination element type.


Type Erasure

Understanding type erasure explains many of the seemingly arbitrary limitations of Java generics.

What type erasure means

Java generics are a compile-time feature only. At runtime, all type parameters are erased — replaced by their bound (or Object if unbounded). The compiled bytecode contains no trace of the original type parameters.

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

// After type erasure — what the JVM actually sees
List  strings  = new ArrayList();
List  integers = new ArrayList();

At runtime, List and List are the same class: java.util.ArrayList.

Consequences of type erasure

1. Cannot use instanceof with type parameters:

List<String> list = new ArrayList<>();
if (list instanceof List<String>) { }   // COMPILE ERROR — cannot check generic type at runtime
if (list instanceof List<?>) { }        // fine — wildcard is allowed

2. Cannot create instances of type parameters:

public class Container<T> {
    private T value;

    public void initialize() {
        value = new T();   // COMPILE ERROR — T is unknown at runtime
    }
}

Workaround: pass a Class object and use reflection, or pass a Supplier:

public class Container<T> {
    private T value;

    public void initialize(Supplier<T> factory) {
        value = factory.get();   // fine — factory knows the concrete type
    }
}

3. Cannot create generic arrays:

T[] array = new T[10];   // COMPILE ERROR — type not known at runtime

// Workaround: create Object[] and cast (generates an unchecked warning)
@SuppressWarnings("unchecked")
T[] array = (T[]) new Object[10];

4. Static members cannot use the class type parameter:

public class Pair<A, B> {
    private static A defaultFirst;   // COMPILE ERROR — static context has no instance type
}

Static members belong to the class itself, not to any parameterized instance. Since the type parameter is erased, a static field of type A is meaningless at the class level.

Why Java chose type erasure

Erasure was chosen for backward compatibility — existing Java 1.4 code (before generics) had to continue working without recompilation. The trade-off was losing runtime type information for generic types. Other languages (like C# with reified generics) made a different choice and retained type information at runtime, but required a complete platform redesign.

Despite its limitations, type erasure is largely invisible in everyday Java programming. The compile-time safety generics provide is the valuable part, and that is preserved.


Generic Interfaces

Interfaces can also be generic. You implement them just like you would a non-generic interface, but you supply the concrete type:

public interface Repository<T, ID> {
    void save(T entity);
    T findById(ID id);
    List<T> findAll();
    void delete(ID id);
    int count();
}
public class User {
    private final int id;
    private final String name;

    public User(int id, String name) {
        this.id   = id;
        this.name = name;
    }

    public int    getId()   { return id; }
    public String getName() { return name; }

    @Override
    public String toString() { return "User[" + id + ", " + name + "]"; }
}
public class UserRepository implements Repository<User, Integer> {

    private final Map<Integer, User> store = new HashMap<>();

    @Override
    public void save(User user) {
        store.put(user.getId(), user);
    }

    @Override
    public User findById(Integer id) {
        return store.get(id);
    }

    @Override
    public List<User> findAll() {
        return new ArrayList<>(store.values());
    }

    @Override
    public void delete(Integer id) {
        store.remove(id);
    }

    @Override
    public int count() {
        return store.size();
    }
}
UserRepository repo = new UserRepository();
repo.save(new User(1, "Alice"));
repo.save(new User(2, "Bob"));
repo.save(new User(3, "Charlie"));

System.out.println(repo.findById(2));    // User[2, Bob]
System.out.println(repo.findAll());      // [User[1, Alice], User[2, Bob], User[3, Charlie]]
System.out.println(repo.count());        // 3

repo.delete(2);
System.out.println(repo.count());        // 2

The Repository interface is the same pattern used by Spring Data — the most popular Java persistence framework. Defining it generically means the same interface works for User, Product, Order, or any entity — you simply implement it with the appropriate type arguments.


Common Generic Patterns in the JDK

Generics appear throughout the Java standard library. Recognizing these patterns in JDK source code deepens your understanding.

Comparable\

public interface Comparable<T> {
    int compareTo(T other);
}

Every class that implements Comparable is self-parameterized: String implements Comparable, Integer implements Comparable. This ensures compareTo always takes the same type — you cannot accidentally compare a String to an Integer.

Iterable\ and Iterator\

public interface Iterable<T> {
    Iterator<T> iterator();
}

public interface Iterator<T> {
    boolean hasNext();
    T next();
    default void remove() { throw new UnsupportedOperationException(); }
}

Making your own class iterable is as simple as implementing Iterable and providing an Iterator. This gives it first-class support in the enhanced for loop.

Optional\ (Java 8+)

Optional is a generic container that either holds a value of type T or is empty. It makes the possibility of absence explicit in the type system, reducing accidental NullPointerException:

public static Optional<String> findByName(List<String> list, String prefix) {
    for (String s : list) {
        if (s.startsWith(prefix)) return Optional.of(s);
    }
    return Optional.empty();
}
List<String> names = List.of("Alice", "Bob", "Charlie");

Optional<String> result = findByName(names, "B");
result.ifPresent(name -> System.out.println("Found: " + name));   // Found: Bob

String fallback = findByName(names, "Z").orElse("Unknown");
System.out.println(fallback);   // Unknown

You will use Optional extensively with the Stream API in Lecture 12.

Comparator\

@FunctionalInterface
public interface Comparator<T> {
    int compare(T o1, T o2);
    // plus many default and static methods
}

Comparator is a functional interface for defining custom orderings. Its static factory methods make creating comparators expressive:

List<String> names = new ArrayList<>(List.of("Charlie", "Alice", "Bob", "Dave"));

// Sort by length, then alphabetically for ties
names.sort(Comparator.comparingInt(String::length)
                     .thenComparing(Comparator.naturalOrder()));

System.out.println(names);   // [Bob, Alice, Dave, Charlie]

Summary

  • Generics enable type-safe, reusable code by parameterizing classes, interfaces, and methods with type parameters. They move type errors from runtime to compile time and eliminate the need for casts.
  • A generic class declares type parameters in <> after the class name: class Stack. A generic method declares its own type parameters before the return type: public static T last(T[] arr).
  • Naming conventions use single uppercase letters: T (type), E (element), K/V (key/value), R (return type), N (number).
  • Bounded type parameters restrict the acceptable types: > means T must implement Comparable. Multiple bounds use &: .
  • Wildcards represent unknown types. is unbounded. allows reading (producer). allows writing (consumer). The PECS rule — Producer Extends, Consumer Super — guides which to choose.
  • Generics are invariant: List is not a List. Use wildcards when you need covariance or contravariance.
  • Type erasure removes all generic type information at runtime. Consequences: cannot use instanceof with parameterized types, cannot instantiate type parameters with new T(), and cannot create generic arrays directly.
  • Generic interfaces work the same way as generic classes. The Repository pattern is the foundation of frameworks like Spring Data.

  • Exercises

    Exercise 1 — Generic Pair and Triple Implement a generic Pair class (as shown in this lecture) and a generic Triple class. Both should be immutable (all fields private final). Add swap() to Pair, and toList() to Triple that returns a List containing all three elements. Override equals(), hashCode(), and toString(). Demonstrate with at least three different type combinations.

    Exercise 2 — Generic bounded max/min Write two generic static methods max(List list) and min(List list) where T extends Comparable. Both should throw IllegalArgumentException if the list is null or empty. Test with List, List, and List. Then write a third method clamp(T value, T low, T high) that returns value clamped between low and high.

    Exercise 3 — Generic filter and map Implement the following generic utility methods in a Streams utility class:

    • List filter(List list, Predicate predicate)
    • List map(List list, Function mapper)
    • R reduce(List list, R identity, BiFunction accumulator)

    Use them to: filter a list of integers to only evens, map a list of strings to their lengths, and reduce a list of integers to their sum. Avoid using the Stream API — implement manually with loops.

    Exercise 4 — Wildcard in practice Write the following methods and verify they compile and work correctly with multiple concrete types:

    • void printList(List list) — prints all elements
    • double sumNumbers(List numbers) — sums any numeric list
    • void fillWithDefault(List list, int count, int defaultValue) — adds count copies of defaultValue

    Demonstrate that sumNumbers works with List, List, and List, and that fillWithDefault works with List, List, and List.

    Exercise 5 — Generic Repository Implement the Repository interface from section 8 with two concrete implementations:

    • ProductRepository where Product has id (int), name (String), and price (double)
    • OrderRepository where Order has id (String), productId (int), and quantity (int)

    Add an extra method findAll(Predicate filter) to the interface as a default method that delegates to findAll() and filters the result. Use it to find all products priced above $20 and all orders with quantity greater than 5.

    Exercise 6 — Type erasure observation Write a program that demonstrates the effects of type erasure:

    1. Create a List and a List and print list.getClass() for both — observe they are the same class.
    2. Show that list instanceof List compiles but list instanceof List does not.
    3. Write a method T[] toArray(List list, Class clazz) that creates a typed array using Array.newInstance(clazz, list.size()) (from java.lang.reflect) and explain in a comment why Class is necessary.

    Up next: Lecture 10 — Exception Handling — where you will learn how Java signals and recovers from errors, the difference between checked and unchecked exceptions, how to create your own exception types, and the best practices that keep exception handling from turning into a maintenance nightmare.

    Leave a Reply

    Your email address will not be published. Required fields are marked *