Skip to content

18. Generics in Java

Table of Contents


Java Generics allow you to create classes, interfaces, and methods that work with user-specified types, ensuring that only objects of the correct type are used.

All type checks are performed by the compiler at compile time.

During compilation, the compiler verifies type correctness and then removes the generic type information, replacing it with concrete types (a process known as type erasure) or with Object when necessary.

The resulting bytecode does not contain generics: it only contains concrete types and, when needed, casts automatically inserted by the compiler.

In this way, type errors are caught before execution, making the code safer, more readable, and more reusable.

Generics apply to:

  • Classes
  • Interfaces
  • Methods (generic methods)
  • Constructors

18.1 Generic Type Basics

A generic class or interface introduces one or more type parameters, enclosed in angle brackets.

class Box<T> {
    private T value;
    void set(T v) { value = v; }
    T get()       { return value; }
}

Box<String> b = new Box<>();
b.set("hello");
String x = b.get(); // no cast needed

Multiple type parameters are allowed:

class Pair<K, V> {
    K key;
    V value;
}

18.2 Why Generics Exist

List list = new ArrayList();          // pre-generics
list.add("hi");
Integer x = (Integer) list.get(0);    // ClassCastException at runtime

With generics:

List<String> list = new ArrayList<>();
list.add("hi");
String x = list.get(0);               // type-safe, no cast

18.3 Generic Methods

A generic method introduces its own type parameter(s), independent of the class.

class Util {
    static <T> T pick(T a, T b) { return a; }
}

String s = Util.<String>pick("A", "B"); // explicit
String t = Util.pick("A", "B");         // inference works

18.4 Type Erasure

Type erasure is the process by which the Java compiler removes all generic type information before generating bytecode.

This ensures backward compatibility with pre-Java-5 JVMs.

At compile time, generics are fully checked: type bounds, variance, method overloading with generics, etc. However, at runtime, all generic information disappears.

18.4.1 How Type Erasure Works

  • Replace all type variables (like T) with their erasure.
  • Insert casts where needed.
  • Remove all generic type arguments (e.g., List<String>List).

18.4.2 Erasure of Unbounded Type Parameters

If a type variable has no bound:

class Box<T> {
    T value;
    T get() { return value; }
}

The erasure of T is Object.

class Box {
    Object value;
    Object get() { return value; }
}

18.4.3 Erasure of Bounded Type Parameters

If the type parameter has bounds:

class TaskRunner<T extends Runnable> {
    void run(T task) { task.run(); }
}

Then the erasure of T is the first bound: Runnable.

class TaskRunner {
    void run(Runnable task) { task.run(); }
}

18.4.4 Multiple Bounds: The First Bound Determines Erasure

Java allows multiple bounds:

<T extends Runnable & Serializable & Cloneable>

The critical rule:

Important

The erasure of T is always the first bound, which must be a class or interface.

Because Runnable is the first bound, the compiler erases T to Runnable.

-Example with Multiple Bounds (Fully Expanded)

public static <T extends Runnable & Serializable & Cloneable>
void runAll(List<T> list) {
    for (T t : list) {
        t.run();
    }
}

Erased Version

public static void runAll(List list) {
    for (Object obj : list) {
        Runnable t = (Runnable) obj;   // cast set by the compiler
        t.run();
    }
}

What happens to the other bounds (Serializable, Cloneable)?

  • They are enforced only at compile time.
  • They do NOT appear in bytecode.
  • No additional interfaces are attached to the erased type.

18.4.5 Why Only the First Bound Becomes the Runtime Type?

Because the JVM must operate using a single, concrete reference type for each variable or parameter.

Runtime bytecode instructions like invokevirtual require a single class or interface, not a composite type such as “Runnable & Serializable & Cloneable”.

Thus:

Note

Java selects the first bound as the runtime type, and uses the remaining bounds for compile-time validation only.

18.4.6 A More Complex Example

interface A { void a(); }
interface B { void b(); }

class C implements A, B {
    public void a() {}
    public void b() {}
}

class Demo<T extends A & B> {
    void test(T value) {
        value.a();
        value.b();
    }
}

Erased Version:

class Demo {
    void test(A value) {
        value.a();
        // value.b();   // ❌ not available after erasure: type is A, not B
    }
}

Note

The compiler may insert additional casts or bridge methods in more complex inheritance scenarios, but erasure always uses only the first bound (A in this case).

18.4.7 Overriding and Generics

When generics interact with inheritance, two fundamental rules must be clearly understood:

Important

Override is checked after type erasure.

Type compatibility is checked before type erasure.

These two steps explain why some methods override correctly while others produce compile-time errors.

18.4.7.1 How the Compiler Validates an Override

When a subclass declares a method that might override a superclass method, the compiler performs two checks:

  1. Before erasure

    • The method must be type-compatible with the parent method:

      • Same method name
      • Same parameter types (including generic arguments)
      • Compatible return type (covariant allowed)
  2. After erasure

    • The erased signatures must match exactly.

      • Both conditions must be satisfied.

18.4.7.2 Generic Parameters and Overriding

Generic type arguments are part of the method signature at compile time, but disappear after erasure.

Because of this:

  • You are allowed to erase generic information in the overriding method
  • You are NOT allowed to introduce new generic specificity
  • If both methods declare parameterized types, they must match exactly

18.4.7.3 Valid Override - Erasing Generic Specificity

class Parent {
    void process(Set<Integer> data) {}
}

class Child extends Parent {
    @Override
    void process(Set data) {}   // ✔ allowed (raw type)
}

Explanation:

  • Before erasure: Set is assignment-compatible with Set<Integer>
  • After erasure: both become Set

✔ Valid override.

18.4.7.4 Invalid Override - Adding Generic Specificity

class Parent {
    void process(Set data) {}
}

class Child extends Parent {
    void process(Set<Integer> data) {}   // ❌ compile error
}

Explanation:

  • Before erasure: Set<Integer> is NOT assignment-compatible with Set
  • The compiler rejects it before even considering erasure

18.4.7.5 Valid Override - Matching Parameterization

class Parent {
    void process(Set<Integer> data) {}
}

class Child extends Parent {
    @Override
    void process(Set<Integer> data) {}   // ✔ exact match
}

Both checks pass: - Compatible before erasure - Identical after erasure

18.4.7.6 Invalid Override - Changing Generic Argument

class Parent {
    void process(Set<Integer> data) {}
}

class Child extends Parent {
    void process(Set<String> data) {}   // ❌ compile error
}

Explanation:

  • Before erasure: Set<String> is not compatible with Set<Integer>
  • After erasure: both would become Set
  • Collision + incompatibility → compile error

18.4.7.7 Why This Rule Exists

Java must guarantee:

  • Compile-time type safety
  • Runtime polymorphism after erasure

Since generics disappear at runtime, the JVM sees only erased signatures. The compiler must therefore ensure compatibility before erasure, and consistency after erasure.

18.4.7.8 Mental Model

Think of overriding with generics as a two-phase check:

Phase 1 → Are the source-level types compatible?
Phase 2 → Do the erased signatures match?

If either phase fails → compilation error.

18.4.7.9 Covariant Returns and Generics

An overriding method (i.e., a method declared in a subclass) is allowed to return a subtype of the return type declared in the overridden method (i.e., the superclass method).

This is known as the rule of covariant returns.

The first step when validating an override is therefore:

  • Check whether the return type of the overriding method is a subtype of the return type declared in the superclass.

Important

  • If the overridden method returns List, the overriding method may return ArrayList.
  • It may not return Object, because Object is a supertype, not a subtype.

When generics are involved, return type validation becomes more subtle.

You must evaluate subtype relationships using the generic type hierarchy rules.

Assume that S is a subtype of T,

There are two important generic hierarchies to remember.

Hierarchy 1 (upper bounded wildcards):

A<S> is a subtype of A<? extends S> which is a subtype of A<? extends T>

  • Example:

Since Integer is a subtype of Number:

  • List<Integer> <<< List<? extends Integer>
  • List<? extends Integer> <<< List<? extends Number>

Therefore, if an overridden method returns:

List<? extends Integer>

the overriding method may return:

  • List<Integer>

but may not return:

  • List<Number>
  • List<? extends Number>

Hierarchy 2 (lower bounded wildcards):

A<T> is a subtype of A<? super T> which is a subtype of A<? super S>

  • Example:

  • List<Number> <<< List<? super Number>

  • List<? super Number> <<< List<? super Integer>

Therefore, if an overridden method returns:

List<? super Number>

the overriding method may return:

  • List<Number>

but may not return:

  • List<Integer>
  • List<? super Integer>

A crucial point to remember:

Even though Integer is a subtype of Number,
List<Integer> is not a subtype of List<Number>.

Generic types in Java are invariant unless wildcards are involved.

These rules explain why certain methods that appear compatible are rejected by the compiler.
Generic specificity must respect formal subtype hierarchies before override validation proceeds to erasure-based signature checks (see 18.4.7.10).

18.4.7.10 Summary Rules

  • Override is validated after erasure
  • Compatibility is validated before erasure
  • You may erase generic information in the subclass
  • You may NOT introduce new generic specificity
  • If both methods are parameterized, arguments must match exactly
  • After erasure, signatures must be identical
  • Covariant return types require the overriding return type to be a true subtype
  • With generics, subtype relationships must follow wildcard hierarchy rules
  • Apparent logical relationships between type arguments (e.g., Integer and Number) do not automatically translate into subtype relationships between parameterized types

This explains why some methods that look like overloads are rejected: after erasure they collide, and if they are not valid overrides, the compiler blocks them.

18.4.8 Overloading a Generic Method — Why Some Overloads Are Impossible

When Java compiles generic code, it applies type erasure: type parameters such as T are removed, and the compiler substitutes them with their erased type (usually Object or the first bound).

Because of this, two methods that look different at the source level may become identical after erasure.

If the erased signatures are the same, Java cannot distinguish between them, therefore the code does not compile.

  • Example: Two Methods That Collapse to the Same Signature
public class Demo {
    public void testInput(List<Object> inputParam) {}

    // public void testInput(List<String> inputParam) {}   // ❌ Compile error: after erasure, both become testInput(List)
}

Explanation

List<Object> and List<String> are both erased to List.

At runtime both methods would appear as:

void testInput(List inputParam)

Java does not allow two methods with identical signatures in the same class, so the overload is rejected at compile time.

18.4.9 Overloading a Generic Method Inherited from a Parent Class

The same rule applies when a subclass tries to introduce a method that erases to the same signature as one in its superclass.

public class SubDemo extends Demo {
    public void testInput(List<Integer> inputParam) {} 
    // ❌ Compile error: erases to testInput(List), same as parent
}

Again, the compiler rejects the overload because the erased signatures collide.

When Overloading Does Work

Erasure only removes type parameters, not the actual class used in the method parameter.

Therefore, if two method parameters differ in their raw (non-generic) type, the overload is legal, even if one is a generic parameterized type.

public class Demo {
    public void testInput(List<Object> inputParam) {}
    public void testInput(ArrayList<String> inputParam) {}  // ✔ Compiles
}

Why this works

Even though ArrayList<String> erases to ArrayList, and List<Object> erases to List, these are different classes (ArrayList vs. List), so the signatures remain distinct:

void testInput(List inputParam)
void testInput(ArrayList inputParam)

No collision → legal overloading.

18.4.10 Returning Generic Types — Rules and Restrictions

When returning a value from a method, Java follows a strict rule:

The return type of an overriding method must be a subtype of the parent's return type, and any generic arguments must remain type-compatible (even though they are erased at runtime).

This often confuses developers, because generics on return types cause similar erasure-based conflicts as parameter types.

Key Points:

  • Return type covariance applies only to the raw type, not the generic arguments.
  • Generic arguments must remain compatible after erasure (they must match).
  • Two methods cannot differ only by generic parameter on the return type.

  • Example: Illegal Return Type Change Due to Generic Mismatch

class A {
    List<String> getData() { return null; }
}

class B extends A {
    // List<Integer> is not a covariant return type of List<String>
    // ❌ Compile error
    List<Integer> getData() { return null; }
}

Explanation:

Even though generics are erased, Java still enforces source-level type safety:

List<Integer> is not a subtype of List<String>.

Both erase to List, but Java rejects overriding that breaks type compatibility.

  • Example: Legal Covariant Return Type
class A {
    Collection<String> getData() { return null; }
}

class B extends A {
    List<String> getData() { return null; }  // ✔ List is a subtype of Collection
}

This is allowed because:

  • The raw types are covariant (List extends Collection).
  • The generic arguments match (String vs. String).

  • Example: Illegal Overload on Return Type Alone

Two methods differing only by the generic argument in the return type cannot coexist:

class Demo {
    List<String> getList() { return null; }

    // List<Integer> getList() { return null; }  
    // ❌ Compile error: return type alone does not distinguish methods
}

Java does not use the return type when distinguishing overloaded methods.

18.4.11 Summary of Erasure Rules

  • Unbounded T → erased to Object.
  • T extends X → erased to X.
  • T extends X & Y & Z → erased to X.
  • All generic parameters are erased in method signatures.
  • Casts are inserted to preserve compile-time typing.
  • Bridge methods may be generated to preserve polymorphism.

18.5 Bounds on Type Parameters

This section introduces bounds on type parameters and wildcards in Java generics.
Bounds restrict the set of types that can be used with a generic type parameter or wildcard.

They are used to enforce type constraints and to express relationships between types in generic code.

Bounds appear in two main forms:

  • Type parameter bounds using extends
  • Wildcard bounds using ?, ? extends, and ? super

These mechanisms allow generic APIs to specify what kinds of types are acceptable and what operations are type-safe.

Rules

  • T extends Type → the type parameter must be Type or a subclass.
  • T extends Class & Interface1 & Interface2 → multiple bounds are allowed.
  • In multiple bounds, the class must appear first.
  • ? represents an unknown type.
  • ? extends Type → accepts types that are Type or subclasses.
  • ? super Type → accepts types that are Type or superclasses.
  • ? extends allows reading (extraction) but forbids insertion.
  • ? super allows writing (insertion) but reading returns Object.

Summary Table

Syntax Meaning Assignment Compatibility Read Write
<T extends Number> Type parameter must be Number or subclass Generic declaration bound T T
<T extends Class & Interface> Multiple bounds Generic declaration bound T T
List<?> Unknown element type Any List<T> Object
List<? extends Number> Unknown subtype of Number List<Integer>, List<Double>, etc. Number
List<? super Integer> Integer or supertype List<Integer>, List<Number>, List<Object> Object Integer

18.5.1 Upper Bounds: extends

<T extends Number> means T must be Number or a subclass.

class Stats<T extends Number> {
    T num;
    Stats(T num) { this.num = num; }
}

18.5.2 Multiple Bounds

Syntax: T extends Class & Interface1 & Interface2 ...

The class must come first.

class C<T extends Number & Comparable<T>> { }

18.5.3 Wildcards: ?, ? extends, ? super

18.5.3.1 Unbounded Wildcard ?

Use when you want to accept a list of unknown type:

void printAll(List<?> list) { ... }

18.5.3.2 Upper-Bounded Wildcard ? extends

List<? extends Number> nums = List.of(1, 2, 3);
Number n = nums.get(0);   // OK
// nums.add(5);           // ❌ cannot add: type safety

You cannot add elements (except null) to ? extends because you don’t know the exact subtype.

18.5.3.3 Lower-Bounded Wildcard ? super

<? super Integer> means the type must be Integer or a superclass of Integer.

List<? super Integer> list = new ArrayList<Number>();
list.add(10);    // OK
Object o = list.get(0); // returns Object (lowest common supertype)

Important

  • Super accepts insertion
  • extends accepts extraction.

18.6 Generics and Inheritance

Generics do NOT participate in inheritance.

A List<String> is not a subtype of List<Object>; parameterized types are invariant.

List<String> ls = new ArrayList<>();
List<Object> lo = ls;      // ❌ compile error

Instead:

List<? extends Object> ok = ls;   // works

18.7 Type Inference (Diamond Operator)

Map<String, List<Integer>> map = new HashMap<>();

The compiler infers generic arguments from the assignment.


18.8 Raw Types (Legacy Compatibility)

A raw type disables generics, re-introducing unsafe behavior.

List raw = new ArrayList();
raw.add("x");
raw.add(10);   // allowed, but unsafe

Raw types should be avoided.


18.9 Generic Arrays (Not Allowed)

You cannot create arrays of parameterized types:

List<String>[] arr = new List<String>[10];   // ❌ compile error

Because arrays enforce runtime type safety while generics rely on compile-time checks only.


18.10 Bounded Type Inference

static <T extends Number> T identity(T x) { return x; }

int v = identity(10);   // OK
// String s = identity("x"); // ❌ not a Number

18.11 Wildcards vs. Type Parameters

Use wildcards when you need flexibility in parameters.
Use type parameters when the method must return or maintain type information.

Example — wildcard too weak:

List<?> copy(List<?> list) {
   return list;  // loses type information
}

Better:

<T> List<T> copy(List<T> list) {
    return list;
}

18.12 PECS Rule (Producer Extends, Consumer Super)

Use ? extends when the parameter produces values.
Use ? super when the parameter consumes values.

List<? extends Number> listExtends = List.of(1, 2, 3);
List<? super Integer> listSuper = new ArrayList<Number>();

// ? extends → safe read
Number n = listExtends.get(0);

// ? super → safe write
listSuper.add(10);

18.13 Common Pitfalls

  • Sorting lists with wildcards: List<? extends Number> cannot accept insertions.
  • Misunderstanding that List<Object> is NOT a supertype of List<String>.
  • Forgetting generic arrays are illegal.
  • Thinking generic types are preserved at runtime (they are erased).
  • Trying to overload methods using only different type parameters.

18.14 Summary Table of Wildcards

Syntax Meaning
? unknown type (read-only except Object methods)
? extends T read T safely, cannot add (except null)
? super T can add T, retrieving gives Object

18.15 Summary of Concepts

Generics = compile-time type safety
Bounds = restrict legal types
Wildcards = flexibility in parameters
Type Inference = compiler deduces types
Type Erasure = generics disappear at runtime
Bridge Methods = maintain polymorphism

18.16 Complete Example

class Repository<T extends Number> {
    private final List<T> store = new ArrayList<>();

    void add(T value) { store.add(value); }

    T first() { return store.isEmpty() ? null : store.get(0); }

    // generic method with wildcard
    static double sum(List<? extends Number> list) {
        double total = 0;
        for (Number n : list) total += n.doubleValue();
        return total;
    }
}