Java Generics: The Complete Developer’s Reference

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

  1. Type variables are compile-time placeholders. When you write Pair<F, S>, F and S are 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).
  2. The diamond operator <> infers type arguments. Writing new Pair<>(...) instead of new Pair<String, Integer>(...) lets the compiler infer the types from the constructor arguments. This eliminates redundancy without losing type safety.
  3. Upper bounds (<T extends Number>) unlock methods. Without a bound, only Object methods are available on a type variable. Adding extends Number makes all Number methods (like doubleValue()) available inside the generic code.
  4. 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 an extends-bounded list or read a typed value from a super-bounded list.
  5. 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 with JpaRepository<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. Use new Object[10] and cast, or use a List<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 wildcard reference. 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

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.

Further Reading

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.