Java generics let us write classes, interfaces and methods with type parameters, such as List<E>, so the compiler checks the element types for us and our code needs no casts. Generics were added in Java 5, and in Java 25 every collection, stream, Optional and functional interface in the JDK is generic.
We use generics to write one class or method that works with many types and stays type-safe, for example a paged API response that holds books on one page and users on another, or a utility method that returns the largest element of any list.
The following example uses the generic interface List<String>, a generic record Page<T> and a generic method firstOrDefault(), all of which we build in this tutorial.
List<String> fruits = new ArrayList<>();
fruits.add("apple");
fruits.add("banana");
String first = fruits.get(0); // "apple"
Page<String> page = new Page<>(fruits, 1, 2); // Page[items=[apple, banana], number=1, totalItems=2]
String top = firstOrDefault(page.items(), "none"); // "apple"
String empty = firstOrDefault(List.of(), "none"); // "none"
Notice that fruits.get(0) returns a String without a cast, and fruits.add(42) would not compile. We start with the problem generics solve, write generic classes and methods, restrict type parameters with bounds and wildcards, and see what type erasure leaves at runtime. The last part lists what generics cannot do in Java 25 and the workaround for each rule.
1. Why Java Needs Generics
Before Java 5, a List stored Object references, so it accepted any type, and every read needed a cast. A wrong element was found only when a cast failed at runtime, often far away from the code that added it. Generics move that check to the compiler.

The following example shows the old style, called a raw type, which compiles with an unchecked warning. The second element is an Integer, and the cast to String fails when we read it.
List rawNames = new ArrayList();
rawNames.add("apple");
rawNames.add(42);
String second = (String) rawNames.get(1); // ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String
For example, a cache in an online shop keeps product names in a raw List. A new feature adds product IDs to the same list, and the checkout page fails with a ClassCastException in production, two modules away from the bug. With List<String>, the same mistake stops the build.
// does not compile: incompatible types: int cannot be converted to String
List<String> names = new ArrayList<>();
names.add(42);
Raw types exist only for compatibility with code written before Java 5, so new code always passes a type argument. The compiler flags raw types with -Xlint:rawtypes and unchecked calls with -Xlint:unchecked.
2. Generic Classes, Interfaces and Records
A class becomes generic when it declares type parameters in angle brackets after its name. Inside the class, the type parameter works like a normal type in fields, parameters and return types. Every Java collection is declared this way, such as ArrayList<E> and HashMap<K, V>.
2.1. A Generic Record
A REST API that returns lists in pages needs the same wrapper for books, authors and loans. Since Java 16, a record can declare type parameters too, so one Page<T> covers every element type.
public record Page<T>(List<T> items, int number, int totalItems) {
public Page {
items = List.copyOf(items);
}
public boolean isEmpty() {
return items.isEmpty();
}
}
When we create an instance, the type argument replaces T. The diamond operator <>, added in Java 7, lets the compiler infer the type argument from the declared type, and var works with generic types as long as the right side names the type argument.
Page<String> fruitPage = new Page<>(List.of("apple", "banana"), 1, 2);
String firstFruit = fruitPage.items().get(0); // "apple"
var numberPage = new Page<Integer>(List.of(5, 3), 2, 4);
int firstNumber = numberPage.items().get(0); // 5
boolean emptyPage = new Page<String>(List.of(), 3, 4).isEmpty(); // true
2.2. A Generic Interface
A generic interface declares type parameters that each implementation either fixes or passes on. The interface Repository has two of them, the entity type T and the ID type ID, which is how Spring Data declares its repositories.
public interface Repository<T, ID> {
Optional<T> findById(ID id);
void save(T item);
}
public record Book(int id, String title) {
}
public class BookRepository implements Repository<Book, Integer> {
private final Map<Integer, Book> books = new HashMap<>();
@Override
public Optional<Book> findById(Integer id) {
return Optional.ofNullable(books.get(id));
}
@Override
public void save(Book book) {
books.put(book.id(), book);
}
}
BookRepository repository = new BookRepository();
repository.save(new Book(1, "Dune"));
String title = repository.findById(1).map(Book::title).orElse("not found"); // "Dune"
String missing = repository.findById(9).map(Book::title).orElse("not found"); // "not found"
The class BookRepository fixes both type arguments, so its methods take a Book and an Integer. A class can also stay generic and pass the parameters on, as in class InMemoryRepository<T, ID> implements Repository<T, ID>.
2.3. Type Parameter Naming Conventions
Type parameter names are single uppercase letters by convention, so a reader can tell them apart from class names at a glance. The JDK uses the same letters everywhere.
| Letter | Meaning | JDK example |
|---|---|---|
| E | Element of a collection | List<E>, Set<E> |
| K, V | Key and value of a map | Map<K, V> |
| T, U | Any type, first and second | Optional<T>, BiFunction<T, U, R> |
| R | Result type of a function | Function<T, R> |
| N | Number | Common in user code, such as <N extends Number> |
3. Generic Methods and Constructors
A generic method declares its own type parameters before the return type. The parameters belong to that method only, so a generic method can live in a class that is not generic, such as a utility class with static methods. Collections.emptyList() and List.of() are generic methods.
static <T> T firstOrDefault(List<T> items, T fallback) {
return items.isEmpty() ? fallback : items.get(0);
}
The compiler infers T from the arguments and from the target type of the assignment. In the rare case where inference picks the wrong type, we pass a type witness before the method name.
String firstName = firstOrDefault(List.of("apple", "banana"), "none"); // "apple"
Integer firstCount = firstOrDefault(List.of(5, 3), 0); // 5
List<String> noNames = Collections.<String>emptyList(); // []
A constructor can declare type parameters too, independent of the class. The class Price is not generic, but its constructor accepts any Number subtype and converts it to a BigDecimal.
public class Price {
private final BigDecimal amount;
public <N extends Number> Price(N value) {
this.amount = new BigDecimal(value.toString());
}
public BigDecimal getAmount() {
return amount;
}
}
BigDecimal fromInt = new Price(5).getAmount(); // 5
BigDecimal fromDouble = new Price(2.5).getAmount(); // 2.5
4. Bounded Type Parameters
A plain T can be any reference type, so the method body can call only the methods of Object. An upper bound written as T extends SomeType restricts the type arguments to that type and its subtypes. In return, the method body can call the methods of the bound.
For example, a leaderboard service needs the highest score, the latest date or the last name in alphabetical order. All of these types implement Comparable, so one bounded method covers them.
static <T extends Comparable<? super T>> T max(List<T> items) {
T best = items.get(0);
for (T item : items) {
if (item.compareTo(best) > 0) {
best = item;
}
}
return best;
}
int highScore = max(List.of(5, 3, 9)); // 9
String lastName = max(List.of("apple", "banana")); // "banana"
LocalDate latest = max(List.of(LocalDate.of(2026, 1, 5), LocalDate.of(2026, 3, 1))); // 2026-03-01
The bound Comparable<? super T> also accepts types whose compareTo() is declared in a supertype. LocalDate is one of them, because it implements Comparable<ChronoLocalDate>, so T extends Comparable<T> would reject a list of dates.
For example, a volume slider or a discount ratio must stay inside a range. A type parameter can have several bounds joined with &. The first bound can be a class or an interface, and every other bound must be an interface.
public class Ranges {
public static <T extends Number & Comparable<T>> T clamp(T value, T min, T max) {
if (value.compareTo(min) < 0) {
return min;
}
return value.compareTo(max) > 0 ? max : value;
}
}
int volume = Ranges.clamp(15, 0, 10); // 10
double ratio = Ranges.clamp(0.5, 0.0, 1.0); // 0.5
5. Wildcards in Generics
Generic types are invariant. Even though Integer is a subtype of Number, a List<Integer> is not a List<Number>. If the compiler allowed it, code could add a Double through the List<Number> reference into a list that must contain only integers.
// does not compile: incompatible types: ArrayList<Integer> cannot be converted to List<Number>
List<Number> numbers = new ArrayList<Integer>();
Arrays behave differently because they are covariant, so a String[] can be assigned to an Object[]. The array checks every store at runtime and throws an ArrayStoreException, which is the runtime error that generics avoid at compile time.
Object[] items = new String[2];
items[0] = 42; // ArrayStoreException: java.lang.Integer
A wildcard ? stands for an unknown type and makes a method accept a family of parameterized types. The three forms differ in what the method can read from the collection and what it can add to it.
| Wildcard | Accepts | Read elements as | Add elements |
|---|---|---|---|
| List<?> | A list of any type | Object | Only null |
| List<? extends Number> | List<Integer>, List<Double>, List<Number> | Number | Only null |
| List<? super Integer> | List<Integer>, List<Number>, List<Object> | Object | Integer values |
static int countAll(Collection<?> items) {
return items.size();
}
static double sum(List<? extends Number> numbers) {
double total = 0;
for (Number number : numbers) {
total += number.doubleValue();
}
return total;
}
static void addDefaults(List<? super Integer> target) {
target.add(5);
target.add(3);
}
int fruitCount = countAll(Set.of("apple", "banana")); // 2
double intTotal = sum(List.of(5, 3)); // 8.0
double mixedTotal = sum(List.of(2.5, 1.5)); // 4.0
List<Number> target = new ArrayList<>();
addDefaults(target);
int added = target.size(); // 2
A method that only reads from a collection uses ? extends, and a method that only writes into it uses ? super. This rule is known as PECS (producer extends, consumer super), and the JDK applies it in methods such as Collections.copy(List<? super T> dest, List<? extends T> src). Our PECS guide covers the rule with more examples.
6. Type Erasure and What Remains at Runtime
The compiler checks the type arguments and erases them after the check. In the compiled bytecode, every T becomes its leftmost bound, or Object when it has no bound, and the compiler inserts a cast wherever our code reads a T. The JVM never sees List<String>, only List, which kept class files compatible with code compiled before Java 5.
List<String> names = new ArrayList<>();
List<Integer> counts = new ArrayList<>();
boolean sameClass = names.getClass() == counts.getClass(); // true
String className = names.getClass().getName(); // "java.util.ArrayList"
Because the type argument is gone at runtime, instanceof cannot test it. A check against List<?> works, and pattern matching for instanceof binds the result to a variable in one step.
Object value = List.of("apple", "banana");
boolean isList = value instanceof List<?>; // true
int size = value instanceof List<?> list ? list.size() : 0; // 2
// does not compile: Object cannot be safely cast to List<String>
Object value = List.of("apple");
boolean isStringList = value instanceof List<String>;
Erasure removes type arguments from objects and method bodies, but not from declarations. The class file keeps the generic signature of fields, methods and supertypes, and reflection can read it. Libraries such as Jackson and Spring use it to find the element type of a field declared as List<Book>.
public class FruitList extends ArrayList<String> {
}
String superType = FruitList.class.getGenericSuperclass().getTypeName(); // "java.util.ArrayList<java.lang.String>"
Erasure also explains why two overloads that differ only in their type arguments cannot exist in one class. After erasure, both methods have the same signature.
// does not compile: name clash: print(List<Integer>) and print(List<String>) have the same erasure
class Printer {
void print(List<String> names) { }
void print(List<Integer> counts) { }
}
7. Restrictions on Generics and Their Workarounds
Most restrictions follow from erasure, because the JVM does not know T at runtime and cannot create, check or store something of that type. Each rule has a standard workaround, and all of them still apply in Java 25.
| Rule | Compiler error | Workaround |
|---|---|---|
| No primitive type arguments, such as List<int> | unexpected type | Use the wrapper class, such as List<Integer>, or a primitive stream such as IntStream |
| No new T() | unexpected type | Pass a Supplier<T> or a Class<T> |
| No new T[10] | generic array creation | Use a List<T>, or pass an IntFunction<T[]> such as String[]::new |
| No static field of type T | non-static type variable T cannot be referenced from a static context | Make the field an instance field, or use a generic static method |
| No generic exception class | a generic class may not extend java.lang.Throwable | Put the payload in a field of a non-generic exception |
| No instanceof List<String> on an Object | Object cannot be safely cast to List<String> | Test List<?> and check the elements |
Primitive type arguments are the restriction readers meet most. Autoboxing converts between int and wrapper classes such as Integer, so the code reads naturally, but every element is an object on the heap.
// does not compile: unexpected type, required reference, found int
List<int> ids = new ArrayList<>();
List<Integer> ids = new ArrayList<>(List.of(5, 3));
int firstId = ids.get(0); // 5
int idSum = ids.stream().mapToInt(Integer::intValue).sum(); // 8
For new T(), the caller passes the code that creates the object. A Supplier<T> such as ArrayList::new works for any type with a matching constructor, and the JDK uses the same idea in List.toArray(IntFunction) to create arrays of the right type.
public class Pool<T> {
private final Supplier<T> factory;
private final List<T> created = new ArrayList<>();
public Pool(Supplier<T> factory) {
this.factory = factory;
}
public T create() {
T item = factory.get();
created.add(item);
return item;
}
public int size() {
return created.size();
}
}
Pool<StringBuilder> builders = new Pool<>(StringBuilder::new);
StringBuilder builder = builders.create().append("apple"); // apple
int poolSize = builders.size(); // 1
String[] fruitArray = List.of("apple", "banana").toArray(String[]::new); // [apple, banana]
8. Java Generics FAQs
Generic code that compiles on the first try still leaves open points about wildcards, primitive type arguments and runtime type checks.
8.1. What Is the Difference Between <T> and <?>?
A type parameter T names a type, so the method can use it in several places and the compiler keeps them consistent, for example <T> void swap(List<T> list) can read an element and write it back. A wildcard ? means an unknown type that the method never names, so it fits methods that only read elements as Object or only call size() and clear().
8.2. Why Can’t Generics Use Primitive Types?
Erasure replaces T with Object or its bound, and an int is not an Object. So a type argument must be a reference type in Java 25, and we use Integer, Long or Double instead. For large amounts of numbers, int[] or IntStream avoid the boxing cost. Project Valhalla works on lifting this rule, but no released Java version has changed it yet.
8.3. What Is the Difference Between List<Object> and List<?>?
A List<Object> accepts only a List<Object> as an argument, but it allows adding any object. A List<?> accepts a list of any element type, such as List<String>, but allows adding only null, because the compiler does not know the element type.
8.4. Are Generics Checked at Runtime?
No. The compiler checks the type arguments, and the JVM checks only the casts that the compiler inserted. So a raw type or an unchecked cast can put an Integer into a List<String>, and the ClassCastException appears later, when the code reads the element. To get a runtime check at insertion time, we can wrap a collection with Collections.checkedList(list, String.class).
List<String> checked = Collections.checkedList(new ArrayList<>(), String.class);
List raw = checked;
raw.add(42); // ClassCastException: Attempt to insert class java.lang.Integer element into collection with element type class java.lang.String
8.5. Can a Static Method Use the Type Parameter of Its Class?
No. A static method belongs to the class, not to an instance such as Page<String>, so the class parameter T is not defined there. A static method declares its own type parameter instead, as in static <T> Page<T> of(List<T> items).
9. Conclusion
Generics let us declare type parameters on classes, interfaces, records, methods and constructors. The compiler uses them to reject wrong types at compile time and to insert the casts we used to write by hand, so a ClassCastException from a collection becomes a compile error.
Bounds such as T extends Comparable<? super T> let a generic method call methods of the bound. Wildcards make methods accept related parameterized types, with ? extends for reading and ? super for writing.
At runtime, type erasure removes the type arguments from objects, which explains the restrictions on primitives, new T(), generic arrays and instanceof. A Supplier<T>, an IntFunction<T[]> or a wrapper class works around each of them.
10. References
- Java Language Specification, Chapter 4 Types, Values, and Variables
- Generics (The Java Tutorials)
- Restrictions on Generics (The Java Tutorials)
- Generics (dev.java)
- List (Java SE 25 API)
Happy Learning !!