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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
List names = new ArrayList();
names.add("Alice");
names.add(100);
String name = (String) names.get(0);
Prefer parameterized types such as List
Generics and Arrays
Java does not allow direct creation of generic arrays because arrays are reified while generic type parameters are erased at runtime.
class Box<T> {
// T[] values = new T[10];
}
When generic collections are needed, prefer List
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.
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.
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.
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.
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.
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
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.
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
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
Generic parameters can still be used by surrounding classes and methods that perform exception handling or return error information.
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
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
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.
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
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.