Generics are the feature that transformed Java from a language of unchecked casts into one of verified type contracts. Introduced in Java 5, they let you write a class or method once and have the compiler enforce type correctness at every call site — without paying a runtime cost. When you write List<Product> instead of List, the compiler catches a misplaced String at compile time rather than surfacing a ClassCastException at 2 a.m. in production. This guide is a complete, runnable reference: generic classes, generic methods, bounded type parameters, wildcards, the PECS rule, and type erasure — with the patterns you will actually reach for in enterprise Java.
Generic Classes
A generic class is parameterised over one or more type variables declared in angle brackets after the class name. The type variable acts as a placeholder resolved when the class is instantiated.
// A generic wrapper that pairs two values of independent types
public class Pair<F, S> {
private final F first; // type variable F — resolved at instantiation
private final S second; // type variable S — may differ from F
public Pair(F first, S second) {
this.first = first;
this.second = second;
}
public F getFirst() { return first; }
public S getSecond() { return second; }
@Override
public String toString() {
return "(" + first + ", " + second + ")";
}
public static void main(String[] args) {
// The compiler resolves F=String, S=Integer for this instance
Pair<String, Integer> cityPopulation = new Pair<>("Mumbai", 20_667_656);
String city = cityPopulation.getFirst(); // no cast needed
Integer population = cityPopulation.getSecond(); // no cast needed
System.out.println(cityPopulation); // (Mumbai, 20667656)
// A completely different instantiation — same class, different types
Pair<Double, Boolean> priceInStock = new Pair<>(79_999.0, true);
System.out.println(priceInStock); // (79999.0, true)
}
}
Generic Methods
A method can introduce its own type parameters independently of the class that contains it. The type variable is declared in angle brackets before the return type and is inferred by the compiler from the arguments at each call site.
import java.util.Arrays;
import java.util.List;
public class GenericMethods {
// <T> declares T as a method-level type variable; T[] is the return type
// The compiler infers T from the argument at each call site
@SafeVarargs
public static <T> List<T> listOf(T... elements) {
return Arrays.asList(elements);
}
// Two type variables: converts a list of F into a list of S using a mapper function
@FunctionalInterface
interface Mapper<F, S> { S apply(F input); }
public static <F, S> List<S> transform(List<F> source, Mapper<F, S> mapper) {
return source.stream()
.map(mapper::apply)
.toList();
}
// Returns the larger of two Comparable values
// <T extends Comparable<T>> is a bounded type parameter — covered in the next section
public static <T extends Comparable<T>> T max(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}
public static void main(String[] args) {
// T is inferred as String
List<String> cities = listOf("Delhi", "Bengaluru", "Hyderabad");
System.out.println(cities); // [Delhi, Bengaluru, Hyderabad]
// F=String, S=Integer — both inferred from the arguments
List<Integer> lengths = transform(cities, String::length);
System.out.println(lengths); // [5, 9, 9]
// T is inferred as Integer; no explicit type annotation needed
System.out.println(max(42, 17)); // 42
System.out.println(max("apple", "mango")); // mango
}
}
Bounded Type Parameters
Bounds constrain which types may be substituted for a type variable, allowing you to call specific methods on the parameter inside the generic code.
import java.util.List;
public class BoundedTypeParameters {
// Upper bound: T must be Number or a subclass (Integer, Double, Long, etc.)
// Without the bound, calling .doubleValue() would not compile
public static <T extends Number> double sum(List<T> numbers) {
double total = 0;
for (T number : numbers) {
total += number.doubleValue(); // safe because T is guaranteed to be a Number
}
return total;
}
// Multiple bounds: T must implement both Comparable and Cloneable
// When mixing a class bound with interface bounds, the class must come first
public static <T extends Comparable<T> & Cloneable> T clampedMin(T value, T floor) {
return value.compareTo(floor) < 0 ? floor : value;
}
public static void main(String[] args) {
List<Integer> intPrices = List.of(100, 250, 399);
List<Double> doublePrices = List.of(1.5, 2.75, 0.99);
// Both compile because Integer and Double extend Number
System.out.println("Integer sum : " + sum(intPrices)); // 749.0
System.out.println("Double sum : " + sum(doublePrices)); // 5.24
// List<String> would NOT compile here — String does not extend Number:
// sum(List.of("a", "b")); // compile error
}
}
Wildcards and the PECS Rule
Wildcards (?) allow generic code to work with families of parameterised types. The mnemonic PECS — Producer Extends, Consumer Super tells you which wildcard to use.
import java.util.ArrayList;
import java.util.List;
public class WildcardsAndPECS {
// --- Upper-bounded wildcard: ? extends T ---
// Use when the list is a PRODUCER — you only read from it
// Accepts List<Integer>, List<Double>, List<Number> — anything that IS-A Number
public static double sumProducer(List<? extends Number> numbers) {
double total = 0;
for (Number n : numbers) { // reading is safe; the element IS-A Number
total += n.doubleValue();
}
// numbers.add(42); // COMPILE ERROR — can't add: exact type unknown
return total;
}
// --- Lower-bounded wildcard: ? super T ---
// Use when the list is a CONSUMER — you only write into it
// Accepts List<Integer>, List<Number>, List<Object> — any list that can hold an Integer
public static void fillConsumer(List<? super Integer> bucket, int count) {
for (int i = 1; i <= count; i++) {
bucket.add(i); // safe: the list can accept Integer or any supertype
}
// Integer x = bucket.get(0); // COMPILE ERROR — reading gives only Object
}
// --- Unbounded wildcard: ? ---
// Use when you only call methods defined on Object (size, isEmpty, contains, etc.)
public static void printList(List<?> items) {
for (Object item : items) { // the only safe type to read into is Object
System.out.print(item + " ");
}
System.out.println();
}
public static void main(String[] args) {
List<Integer> intPrices = List.of(100, 200, 300);
List<Double> dblAmounts = List.of(1.1, 2.2, 3.3);
// sumProducer accepts both — PECS: list is a producer, use extends
System.out.println("int sum : " + sumProducer(intPrices)); // 600.0
System.out.println("dbl sum : " + sumProducer(dblAmounts)); // 6.6
// fillConsumer accepts List<Integer> or List<Number> — PECS: list is a consumer, use super
List<Number> numberBucket = new ArrayList<>();
fillConsumer(numberBucket, 3);
System.out.println("consumer : " + numberBucket); // [1, 2, 3]
// printList accepts any List — unbounded wildcard
printList(intPrices); // 100 200 300
printList(List.of("Delhi", "Pune")); // Delhi Pune
}
}
Generic Interfaces
Interfaces can be parameterised just like classes. This is the foundation of the entire java.util.function package and most of the Collections API.
import java.util.List;
import java.util.stream.Collectors;
// Generic interface: a repository that can find and save any entity type
public interface Repository<T, ID> {
T findById(ID id);
void save(T entity);
List<T> findAll();
}
// Concrete implementation binds the type variables to specific types
class ProductRepository implements Repository<Product, Integer> {
private final java.util.Map<Integer, Product> store = new java.util.HashMap<>();
@Override
public Product findById(Integer id) {
return store.get(id);
}
@Override
public void save(Product product) {
store.put(product.id(), product);
}
@Override
public List<Product> findAll() {
return List.copyOf(store.values());
}
}
record Product(int id, String name, double price) {}
class RepositoryDemo {
public static void main(String[] args) {
ProductRepository repo = new ProductRepository();
repo.save(new Product(1, "Laptop", 79_999.0));
repo.save(new Product(2, "Phone", 29_999.0));
Product found = repo.findById(1);
System.out.println("Found : " + found.name() + " @ " + found.price());
System.out.println("All : " + repo.findAll().stream()
.map(Product::name).collect(Collectors.joining(", ")));
}
}
How the Code Works
- Type variables are compile-time placeholders. When you write
Pair<F, S>,FandSare resolved by the compiler at each instantiation site. The class bytecode contains a single compiled form; the type variables are erased at runtime (see Type Erasure below). - The diamond operator <> infers type arguments. Writing
new Pair<>(...)instead ofnew Pair<String, Integer>(...)lets the compiler infer the types from the constructor arguments. This eliminates redundancy without losing type safety. - Upper bounds (<T extends Number>) unlock methods. Without a bound, only
Objectmethods are available on a type variable. Addingextends Numbermakes allNumbermethods (likedoubleValue()) available inside the generic code. - PECS governs wildcard direction. A list that produces values for your code to read should use
? extends T(upper bound). A list that consumes values your code writes should use? super T(lower bound). The compiler enforces this: you cannot add to anextends-bounded list or read a typed value from asuper-bounded list. - Generic interfaces decouple abstraction from implementation. Parameterising an interface like
Repository<T, ID>means the same interface contract governs every entity type. Spring Data JPA uses exactly this pattern withJpaRepository<T, ID>.
Sample Output
(Mumbai, 20667656)
(79999.0, true)
[Delhi, Bengaluru, Hyderabad]
[5, 9, 9]
42
mango
Integer sum : 749.0
Double sum : 5.24
int sum : 600.0
dbl sum : 6.600000000000001
consumer : [1, 2, 3]
100 200 300
Delhi Pune
Found : Laptop @ 79999.0
All : Laptop, Phone
Type Erasure: What Happens at Runtime
Java implements generics through type erasure: type arguments are removed during compilation and replaced by their upper bound (or Object if unbounded). This means the JVM sees a single class file for List<String> and List<Integer> — they are the same List at runtime.
import java.util.ArrayList;
import java.util.List;
public class TypeErasureDemo {
public static void main(String[] args) {
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
// At runtime both are just ArrayList; the type arguments are erased
System.out.println(strings.getClass() == integers.getClass()); // true
// You CANNOT use a type parameter in instanceof checks or array creation
// if (strings instanceof List<String>) {} // raw type only in older Java
// T[] array = new T[10]; // compile error — generic array creation
// What you CAN do: pass Class<T> as a runtime token ("type token" pattern)
printIfMatch("hello", String.class); // Matched: hello
printIfMatch(42, String.class); // (no output)
printIfMatch("world", String.class); // Matched: world
}
// Type token pattern: Class<T> carries the type information that erasure removed
public static <T> void printIfMatch(Object candidate, Class<T> type) {
if (type.isInstance(candidate)) {
T typed = type.cast(candidate); // safe, checked cast
System.out.println("Matched: " + typed);
}
}
}
Gotchas Worth Remembering
- Raw types are a trap. Using
List list = new ArrayList()without a type argument disables compile-time type checking for that variable and infects surrounding type inference. Treat any “unchecked” compiler warning as an error to be fixed. - Generic arrays cannot be created directly.
new T[10]is a compile error because array creation requires a concrete type that is known at runtime. Usenew Object[10]and cast, or use aList<T>instead. - Static fields cannot use class-level type parameters. A static field shared across all instances cannot belong to a particular type parameterisation. Use a static generic method if you need type safety at class level without an instance.
- Wildcard capture is subtle. You cannot call a method that takes a type-parameterised argument on an
unbounded wildcardreference. A helper method with a named type parameter is needed to “capture” the wildcard type. - @SuppressWarnings(“unchecked”) should be rare and local. When a cast is truly unavoidable (e.g., when deserialising from a type-token), suppress the warning on the smallest possible scope — a single variable or method — and add a comment explaining why it is safe.
AI Prompts You Can Use
Paste these into Claude, ChatGPT, or Cursor with the relevant code attached:
Prompt 1 — Add Generics to a Raw-Type Class
What it does: Refactors a class that uses raw List or Map into a properly parameterised generic class, adds type bounds where needed, and eliminates all unchecked cast warnings.
Refactor this Java class to use generics instead of raw types. Add type parameters to the class declaration and all method signatures. Introduce upper bounds (<T extends X>) where a method needs to call a specific interface or superclass method. Eliminate all unchecked cast warnings.
Prompt 2 — Choose the Correct Wildcard
What it does: Given a method signature that reads from or writes to a collection, recommends ? extends T, ? super T, or an exact type parameter, and explains the PECS rationale.
Analyse this Java method signature. Decide whether the collection parameter should use an upper-bounded wildcard (? extends T), a lower-bounded wildcard (? super T), or an exact type parameter. Apply the PECS rule and explain your reasoning.
Prompt 3 — Design a Generic Repository Interface
What it does: Scaffolds a generic repository interface for a given entity and ID type, including findById returning Optional, save, delete, and findAll, following the pattern used by Spring Data.
Create a generic Java repository interface for entity [EntityName] with primary key type [IDType]. Include: findById returning Optional<T>, save(T entity), deleteById(ID id), and findAll() returning List<T>. Follow the Spring Data JpaRepository design pattern.
See Also
- Mastering Java Optional: Eliminate NullPointerExceptions for Good
- Java Streams API Deep Dive + Collectors Cookbook
- Java HashMap vs ConcurrentHashMap: Complete Interview Guide
- Mastering Java Comparator: A Practical Guide to Custom Sorting
- A Beginner’s Guide to Java HashSet
- Java 21 to Java 25 LTS: Every Feature You Actually Need to Know
FAQs
What is type erasure and why does it matter?
Type erasure removes generic type arguments at compile time, replacing them with their upper bound (or Object). The JVM sees only one class file for List<String> and List<Integer>. It matters because it prevents generic type information from being available via reflection at runtime, which is why patterns like the type token (Class<T>) and the super type token (TypeReference<T> in Jackson) exist to work around it.
Can I use primitives as type arguments?
No. Type parameters only accept reference types. You must use the boxed equivalents: Integer instead of int, Double instead of double. Autoboxing handles the conversion transparently in most cases, but use the primitive stream specialisations (IntStream, DoubleStream) when performance matters to avoid boxing overhead.
What is the difference between <T extends Number> and <? extends Number>?
<T extends Number> is a bounded type parameter: it introduces a named type variable you can reference multiple times in the same signature (e.g., return type and parameter type must be the same T). <? extends Number> is an anonymous wildcard used in field or parameter types where you do not need to refer to the type again. Use named type parameters in method signatures that must relate two types; use wildcards in API types where the exact type is irrelevant.
Why can’t I create a generic array (new T[10])?
Array creation requires a concrete runtime type to perform type checks when elements are written (array covariance). Because type erasure removes T at runtime, the JVM would not know what type to check against, which could silently corrupt heap state (heap pollution). Use List<T> instead, or create an Object[] and suppress the single unchecked cast with a comment explaining why it is safe.
Conclusion
Generics are everywhere in the Java ecosystem — every collection, every functional interface, every Spring repository, and every Jackson deserialiser depends on them. Mastering the distinction between bounded type parameters and wildcards, internalising the PECS rule, and understanding what type erasure does to your code at runtime will make every hour you spend with the Collections API and frameworks like Spring Data and Hibernate dramatically more productive. The patterns in this guide — generic classes, generic methods, bounded parameters, PECS wildcards, and type tokens — cover the vast majority of what you will see and write in enterprise Java day to day.