Java Generics: Complete Guide

Java Generics allow developers to create reusable, type-safe code that works with different data types. Generics are heavily used throughout the Java Collections Framework and are an essential part of modern Java programming.

Instead of writing separate classes and methods for String, Integer, User, Product, or other types, generics allow a single implementation to work with multiple types while maintaining compile-time type safety.

Why Do We Need Generics?

Before generics, Java collections commonly stored values as Object. Developers had to manually cast values when retrieving them, which could lead to runtime ClassCastException errors.

Java
Using a collection without generics requires explicit casting.
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List names = new ArrayList();
        names.add("Alice");
        names.add("Bob");

        String name = (String) names.get(0);
        System.out.println(name);
    }
}

Generics solve this problem by specifying the type that a collection or class can work with.

Java
Generics provide compile-time type safety.
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        names.add("Alice");
        names.add("Bob");

        String name = names.get(0);
        System.out.println(name);
    }
}

Generic Syntax

Generic types use type parameters inside angle brackets. Common conventions include T for Type, E for Element, K for Key, V for Value, and N for Number.

Java
Basic generic collection syntax.
List<String> names = new ArrayList<>();
Map<Integer, String> users = new HashMap<>();
Set<Long> ids = new HashSet<>();

Generic Classes

A generic class defines one or more type parameters that can be used throughout the class.

Java
A simple generic Box class.
class Box<T> {
    private T value;

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

    public T get() {
        return value;
    }
}

public class Main {
    public static void main(String[] args) {
        Box<String> textBox = new Box<>();
        textBox.set("Hello Java");

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

        System.out.println(textBox.get());
        System.out.println(numberBox.get());
    }
}

The same Box class can safely store different types without requiring casts.

Multiple Type Parameters

A generic class can define multiple type parameters. This is useful when a class represents relationships between different types.

Java
Generic class with key and value types.
class Pair<K, V> {
    private K key;
    private V value;

    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public K getKey() {
        return key;
    }

    public V getValue() {
        return value;
    }
}

public class Main {
    public static void main(String[] args) {
        Pair<Integer, String> user = new Pair<>(101, "Alice");

        System.out.println(user.getKey());
        System.out.println(user.getValue());
    }
}

Generic Methods

A generic method declares its type parameter before the return type. Generic methods can be defined inside generic or non-generic classes.

Java
A generic method that accepts and returns any type.
public class Utility {
    public static <T> T identity(T value) {
        return value;
    }

    public static void main(String[] args) {
        String text = identity("Hello");
        Integer number = identity(100);

        System.out.println(text);
        System.out.println(number);
    }
}

Generic Methods with Collections

Generic methods are particularly useful when processing collections because the method can preserve the element type.

Java
Generic method for printing any collection.
import java.util.List;

public class Utility {
    public static <T> void printItems(List<T> items) {
        for (T item : items) {
            System.out.println(item);
        }
    }

    public static void main(String[] args) {
        printItems(List.of("Java", "Python", "Go"));
        printItems(List.of(10, 20, 30));
    }
}

Generic Interfaces

Interfaces can also define type parameters. Implementing classes can either preserve the generic type or provide a concrete type.

Java
Generic interface with a concrete implementation.
interface Repository<T> {
    void save(T item);
    T findById(int id);
}

class UserRepository implements Repository<String> {
    public void save(String item) {
        System.out.println("Saving: " + item);
    }

    public String findById(int id) {
        return "User-" + id;
    }
}

public class Main {
    public static void main(String[] args) {
        UserRepository repository = new UserRepository();
        repository.save("Alice");
        System.out.println(repository.findById(1));
    }
}

Bounded Type Parameters

A bounded type parameter restricts the types that can be used as generic arguments. The extends keyword is used to specify an upper bound.

Java
Only Number subclasses can be used with this class.
class NumberBox<T extends Number> {
    private T value;

    public NumberBox(T value) {
        this.value = value;
    }

    public double doubleValue() {
        return value.doubleValue();
    }
}

public class Main {
    public static void main(String[] args) {
        NumberBox<Integer> integerBox = new NumberBox<>(100);
        NumberBox<Double> doubleBox = new NumberBox<>(25.5);

        System.out.println(integerBox.doubleValue());
        System.out.println(doubleBox.doubleValue());
    }
}

Multiple Bounds

A type parameter can have multiple bounds. The first bound can be a class, while additional bounds must be interfaces.

Java
Generic type with a class bound and an interface bound.
class Data<T extends Number & Comparable<T>> {
    private final T value;

    public Data(T value) {
        this.value = value;
    }

    public T getValue() {
        return value;
    }
}

Wildcard in Generics

The wildcard character ? represents an unknown type. It is useful when a method should accept a generic type without requiring the exact type parameter.

Java
A wildcard allows a method to accept lists of different types.
import java.util.List;

public class Main {
    public static void printList(List<?> items) {
        for (Object item : items) {
            System.out.println(item);
        }
    }

    public static void main(String[] args) {
        printList(List.of("Java", "Python"));
        printList(List.of(10, 20, 30));
    }
}

Upper-Bounded Wildcards

An upper-bounded wildcard uses ? extends Type. It means the unknown type is Type or a subclass of Type.

Java
Reading numbers from a collection using an upper-bounded wildcard.
import java.util.List;

public class Calculator {
    public static double sum(List<? extends Number> numbers) {
        double total = 0;

        for (Number number : numbers) {
            total += number.doubleValue();
        }

        return total;
    }

    public static void main(String[] args) {
        System.out.println(sum(List.of(10, 20, 30)));
        System.out.println(sum(List.of(1.5, 2.5, 3.5)));
    }
}

Upper-bounded wildcards are commonly used when a method primarily reads values from a generic structure.

Lower-Bounded Wildcards

A lower-bounded wildcard uses ? super Type. It accepts Type or any superclass of Type.

Java
Adding integers to a collection using a lower-bounded wildcard.
import java.util.List;

public class Main {
    public static void addNumbers(List<? super Integer> numbers) {
        numbers.add(10);
        numbers.add(20);
        numbers.add(30);
    }

    public static void main(String[] args) {
        List<Number> numbers = new java.util.ArrayList<>();
        addNumbers(numbers);

        System.out.println(numbers);
    }
}

extends vs super

The most important rule is that ? extends is generally useful when consuming values from a generic structure, while ? super is useful when adding values to a generic structure.

Java
Comparison of upper and lower bounded wildcards.
List<? extends Number> source = List.of(10, 20, 30);
Number value = source.get(0);

List<? super Integer> destination = new ArrayList<Number>();
destination.add(100);
destination.add(200);

This concept is commonly summarized using PECS: Producer Extends, Consumer Super.

PECS: Producer Extends, Consumer Super

PECS is a practical guideline for choosing between extends and super. If a structure produces values for your code to read, consider extends. If your code puts values into the structure, consider super.

Java
Copying values using the PECS principle.
import java.util.List;

public class Copier {
    public static <T> void copy(
            List<? super T> destination,
            List<? extends T> source) {

        for (T item : source) {
            destination.add(item);
        }
    }
}

Diamond Operator

The diamond operator <> allows the compiler to infer generic type arguments when creating objects.

Java
Using the diamond operator with a generic collection.
List<String> names = new ArrayList<>();
Map<Integer, String> users = new HashMap<>();
Box<Double> box = new Box<>();

Raw Types

A raw type is a generic type used without specifying its type parameter. Raw types exist mainly for backward compatibility with older Java code and should generally be avoided in new applications.

Java
Raw types remove compile-time generic type checking.
List names = new ArrayList();
names.add("Alice");
names.add(100);

String name = (String) names.get(0);

Prefer parameterized types such as List or List whenever possible.

Generics and Arrays

Java does not allow direct creation of generic arrays because arrays are reified while generic type parameters are erased at runtime.

Java
Generic arrays cannot be created directly.
class Box<T> {
    // T[] values = new T[10];
}

When generic collections are needed, prefer List or another collection instead of trying to create generic arrays.

Type Erasure

Java implements generics primarily through type erasure. Generic type information is used by the compiler for type checking, but most generic type parameters are not available as concrete runtime types.

Java
The compiler uses generic information while compiling the program.
List<String> names = new ArrayList<>();
List<Integer> numbers = new ArrayList<>();

System.out.println(names.getClass() == numbers.getClass());

Both variables use the same runtime collection class. This is one reason Java does not allow operations such as new T() or new T[10] directly.

Static Members and Generics

A static field cannot use a class type parameter because the type parameter belongs to an instance of the generic class, while static members belong to the class itself.

Java
Generic type parameters belong to instances, not static fields.
class Box<T> {
    private T value;

    // private static T sharedValue; // Not allowed

    public T getValue() {
        return value;
    }
}

A static method can still declare its own independent type parameter.

Java
Static methods can define their own generic type parameters.
class Utility {
    public static <T> T getFirst(T first, T second) {
        return first;
    }
}

Generics and Inheritance

Generic classes can participate in inheritance just like normal classes. A subclass can preserve the generic parameter or specialize it.

Java
Specializing a generic parent class.
class Box<T> {
    protected T value;

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

    public T getValue() {
        return value;
    }
}

class StringBox extends Box<String> {
    public StringBox(String value) {
        super(value);
    }
}

public class Main {
    public static void main(String[] args) {
        StringBox box = new StringBox("Java");
        String value = box.getValue();
        System.out.println(value);
    }
}

Generics with Records

Modern Java records can also use generic type parameters, making them useful for immutable data-transfer structures.

Java
A generic record representing a result.
record Result<T>(boolean success, T data, String message) {}

public class Main {
    public static void main(String[] args) {
        Result<String> result =
                new Result<>(true, "Operation completed", "OK");

        System.out.println(result.data());
    }
}

Generics in Optional

Java's Optional class is itself generic. Optional represents an optional value of type T.

Java
Using Optional with different generic types.
import java.util.Optional;

public class Main {
    public static void main(String[] args) {
        Optional<String> name = Optional.of("Alice");
        Optional<Integer> age = Optional.of(30);

        name.ifPresent(System.out::println);
        age.ifPresent(System.out::println);
    }
}

Generics in Collections

The Java Collections Framework relies heavily on generics. Generic collections provide compile-time guarantees about the values they store.

Java
Common generic collection declarations.
List<String> names = new ArrayList<>();
Set<Integer> ids = new HashSet<>();
Map<Long, String> users = new HashMap<>();
Queue<String> queue = new LinkedList<>();
Deque<Integer> stack = new ArrayDeque<>();

Generics with Comparator

Comparator is a generic functional interface used to define custom ordering rules for objects.

Java
Sorting generic objects using Comparator.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

record User(String name, int age) {}

public class Main {
    public static void main(String[] args) {
        List<User> users = new ArrayList<>(List.of(
                new User("Alice", 30),
                new User("Bob", 22),
                new User("Charlie", 27)
        ));

        users.sort(Comparator.comparingInt(User::age));

        users.forEach(System.out::println);
    }
}

Generics and Exceptions

Java does not allow a generic class to directly extend Throwable. In other words, you cannot create a generic exception type such as class MyException extends Exception.

Generic parameters can still be used by surrounding classes and methods that perform exception handling or return error information.

Java
A generic result type can represent success or failure without using a generic exception.
record ApiResult<T>(T data, String error) {}

public class Main {
    public static ApiResult<String> execute(boolean success) {
        if (success) {
            return new ApiResult<>("Success", null);
        }

        return new ApiResult<>(null, "Operation failed");
    }
}

Generics Best Practices

Use parameterized types instead of raw types. Keep generic APIs simple and meaningful. Prefer descriptive type parameters when a single-letter convention does not clearly communicate the purpose.

Use bounded type parameters when an operation requires specific capabilities. Use wildcards when an API needs flexibility across related generic types.

Avoid unnecessary unchecked casts. If an unchecked operation is unavoidable, isolate it in a small, well-tested section rather than spreading unsafe code throughout the application.

Common Generics Mistakes

Using Raw Types

Raw types remove much of the compile-time safety provided by generics. Prefer List over List.

Using Wildcards Everywhere

Wildcards are useful for flexible APIs, but they are not required for every generic declaration. Use the simplest type expression that accurately represents the API.

Confusing extends with super

Remember the PECS rule: Producer Extends, Consumer Super.

Assuming Generic Types Exist at Runtime

Because of type erasure, runtime code generally cannot distinguish List from List using ordinary class information.

Real-World Generic Repository

Generics are especially useful when building reusable repository or service abstractions. A single repository implementation can operate on different entity types.

Java
A reusable generic repository abstraction.
import java.util.ArrayList;
import java.util.List;

interface Repository<T> {
    void save(T item);
    List<T> findAll();
}

class InMemoryRepository<T> implements Repository<T> {
    private final List<T> items = new ArrayList<>();

    @Override
    public void save(T item) {
        items.add(item);
    }

    @Override
    public List<T> findAll() {
        return List.copyOf(items);
    }
}

record Product(long id, String name) {}

public class Main {
    public static void main(String[] args) {
        Repository<Product> repository = new InMemoryRepository<>();

        repository.save(new Product(1, "Laptop"));
        repository.save(new Product(2, "Keyboard"));

        repository.findAll().forEach(System.out::println);
    }
}

Java Generics Quick Reference

TEXT
Quick reference for common Java generics syntax.
Generic class:        class Box<T> {}
Generic method:       <T> T method(T value)
Generic interface:    interface Repository<T> {}
Multiple parameters:  class Pair<K, V> {}
Upper bound:           <T extends Number>
Wildcard:              <?>
Upper wildcard:        <? extends Number>
Lower wildcard:        <? super Integer>
Generic collection:    List<String>
Diamond operator:      new ArrayList<>()
PECS:                  Producer Extends, Consumer Super

Conclusion

Java Generics are fundamental to writing reusable, type-safe, and maintainable Java applications. They remove unnecessary casting, catch many type errors during compilation, and provide the foundation for the Java Collections Framework.

The most important concepts to understand are generic classes, generic methods, bounded type parameters, wildcards, extends, super, PECS, and type erasure. Once these concepts are clear, advanced Java APIs and frameworks become much easier to understand and use effectively.