Chapter 20 Advanced 45 min min read Updated 2026-04-08

Generics in Java

Practice Questions →

In This Chapter

What Is It?

What Are Generics in Java?

Generics allow you to write classes, interfaces, and methods that operate on a type parameter instead of a specific type. Instead of writing separate IntBox, StringBox, and DoubleBox classes, you write one Box<T> that works with any type.

// Without generics (before Java 5)
ArrayList list = new ArrayList();
list.add("Hello");
list.add(42);            // No error at compile time!
String s = (String) list.get(1);  // ClassCastException at runtime!

// With generics
ArrayList<String> list = new ArrayList<>();
list.add("Hello");
// list.add(42);          // Compilation error! Type safety enforced.
String s = list.get(0);  // No cast needed.

Generics were introduced in Java 5 to provide compile-time type safety and eliminate the need for explicit casting. The type parameter (like T) is a placeholder that gets replaced by a concrete type when the generic class or method is used.

// Generic class
class Box<T> {
    private T value;
    
    Box(T value) { this.value = value; }
    T getValue() { return value; }
}

// Usage
Box<String> stringBox = new Box<>("Hello");
Box<Integer> intBox = new Box<>(42);

String s = stringBox.getValue();  // No cast
int n = intBox.getValue();        // No cast (auto-unboxing)

Why Does It Matter?

Why Do Generics Matter?

Before generics, Java collections held Object references, requiring unsafe casting at every retrieval. Generics transformed Java from a loosely typed collections world into a type-safe one.

1. Type Safety at Compile Time

Without generics, type errors surface at runtime as ClassCastException. With generics, the compiler catches type mismatches before the code runs. When Arjun writes ArrayList<Student>, the compiler guarantees that only Student objects go in. No surprises at runtime.

2. Elimination of Casting

Without generics: String s = (String) list.get(0);. With generics: String s = list.get(0);. The compiler knows the type, so no explicit cast is needed. This reduces boilerplate and potential errors.

3. Code Reusability

A single generic class or method works with all types. Kavya writes Pair<K, V> once and uses it as Pair<String, Integer>, Pair<Student, Grade>, or any other combination. One implementation, infinite type combinations.

4. Algorithm Abstraction

Generic methods allow writing type-independent algorithms. A max method works for any Comparable type without knowing whether it compares Integers, Strings, or custom objects.

5. Foundation of the Collections Framework

Every collection class (ArrayList<E>, HashMap<K,V>, HashSet<E>) uses generics. Understanding generics is essential to understanding collections.

6. Interview Importance

Generics, particularly wildcards and the PECS principle, are among the trickiest Java interview topics. Questions about type erasure, bounded types, and wildcard captures are common in placement interviews at product-based companies.

Detailed Explanation

Detailed Explanation

1. Generic Classes

A generic class declares one or more type parameters in angle brackets after the class name:

class Pair<K, V> {
    private K key;
    private V value;
    
    Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }
    
    K getKey() { return key; }
    V getValue() { return value; }
    
    @Override
    public String toString() {
        return key + " = " + value;
    }
}

// Usage
Pair<String, Integer> age = new Pair<>("Arjun", 20);
Pair<String, String> city = new Pair<>("Kavya", "Bangalore");

String name = age.getKey();    // No cast
int years = age.getValue();    // No cast (auto-unboxing)

Common type parameter naming conventions: T (Type), E (Element), K (Key), V (Value), N (Number), S, U (additional types).

2. Generic Methods

A generic method declares its own type parameter(s), independent of the class's type parameters:

class Utility {
    // Generic method: <T> before return type
    static <T> void printArray(T[] array) {
        for (T element : array) {
            System.out.print(element + " ");
        }
        System.out.println();
    }
    
    static <T> T getFirst(T[] array) {
        return array[0];
    }
}

// Usage: type is inferred from arguments
Integer[] ints = {1, 2, 3, 4, 5};
String[] strs = {"A", "B", "C"};

Utility.printArray(ints);   // 1 2 3 4 5
Utility.printArray(strs);   // A B C

Integer first = Utility.getFirst(ints);  // 1
String firstStr = Utility.getFirst(strs);  // A

3. Bounded Type Parameters

You can restrict type parameters to certain types using extends:

// T must be a subclass of Number
class NumericBox<T extends Number> {
    private T value;
    
    NumericBox(T value) { this.value = value; }
    
    double doubleValue() {
        return value.doubleValue();  // Safe: Number has doubleValue()
    }
}

NumericBox<Integer> intBox = new NumericBox<>(42);
NumericBox<Double> dblBox = new NumericBox<>(3.14);
// NumericBox<String> strBox = new NumericBox<>("Hi");  // Compilation error!

4. Multiple Bounds

A type parameter can have multiple bounds using &:

// T must extend Number AND implement Comparable
class SortableBox<T extends Number & Comparable<T>> {
    private T value;
    
    SortableBox(T value) { this.value = value; }
    
    boolean isGreaterThan(SortableBox<T> other) {
        return this.value.compareTo(other.value) > 0;
    }
}

SortableBox<Integer> a = new SortableBox<>(10);
SortableBox<Integer> b = new SortableBox<>(5);
System.out.println(a.isGreaterThan(b));  // true

With multiple bounds, the class must come first, followed by interfaces: T extends ClassName & Interface1 & Interface2.

5. Wildcards

Wildcards (?) represent an unknown type. They are used in method parameters when you want flexibility:

Unbounded Wildcard: ?

// Accepts a list of any type
void printList(List<?> list) {
    for (Object element : list) {
        System.out.println(element);
    }
}

printList(List.of(1, 2, 3));       // Works
printList(List.of("A", "B"));      // Works
printList(List.of(new Student()));  // Works

Upper Bounded Wildcard: ? extends T

// Accepts List of Number or any subclass of Number
double sum(List<? extends Number> list) {
    double total = 0;
    for (Number n : list) {
        total += n.doubleValue();
    }
    return total;
}

sum(List.of(1, 2, 3));           // List<Integer> works
sum(List.of(1.5, 2.5, 3.5));     // List<Double> works
// sum(List.of("A", "B"));       // List<String> fails (String is not Number)

Lower Bounded Wildcard: ? super T

// Accepts List of Integer or any superclass of Integer
void addIntegers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
    list.add(3);
}

List<Integer> intList = new ArrayList<>();
List<Number> numList = new ArrayList<>();
List<Object> objList = new ArrayList<>();

addIntegers(intList);  // Works
addIntegers(numList);  // Works (Number is super of Integer)
addIntegers(objList);  // Works (Object is super of Integer)

6. PECS Principle (Producer Extends, Consumer Super)

This is the golden rule for deciding between extends and super:

  • Producer Extends: If you only READ from the collection (it produces items), use ? extends T
  • Consumer Super: If you only WRITE to the collection (it consumes items), use ? super T
// Producer: reading from source (extends)
static <T> void copy(List<? extends T> source, List<? super T> dest) {
    for (T item : source) {  // Reading from source (producer)
        dest.add(item);      // Writing to dest (consumer)
    }
}

List<Integer> ints = List.of(1, 2, 3);
List<Number> nums = new ArrayList<>();
copy(ints, nums);  // Copies Integer list into Number list

Think of it this way: extends gives you a read-only view (you know you are getting at least T). super gives you a write-only view (you know you can put at least T).

7. Type Erasure

Generics are a compile-time feature. At runtime, all generic type information is erased (replaced by Object or the bound). This is called type erasure.

// At compile time
ArrayList<String> strings = new ArrayList<>();
ArrayList<Integer> ints = new ArrayList<>();

// At runtime (after erasure)
ArrayList strings = new ArrayList();  // Both become raw ArrayList
ArrayList ints = new ArrayList();

// They have the same class at runtime!
System.out.println(strings.getClass() == ints.getClass());  // true

Consequences of type erasure:

  • Cannot use new T() (type is erased at runtime)
  • Cannot use instanceof with generic types (obj instanceof List<String> is illegal)
  • Cannot create generic arrays (new T[10] is illegal)
  • Cannot have static fields of generic type

8. Generic Interfaces

Interfaces can be generic:

interface Repository<T> {
    void save(T entity);
    T findById(int id);
    List<T> findAll();
}

class StudentRepository implements Repository<Student> {
    private List<Student> students = new ArrayList<>();
    
    @Override
    public void save(Student s) { students.add(s); }
    
    @Override
    public Student findById(int id) {
        return students.stream()
            .filter(s -> s.getId() == id)
            .findFirst()
            .orElse(null);
    }
    
    @Override
    public List<Student> findAll() { return students; }
}

9. Restrictions on Generics

  • No primitive types: Box<int> is illegal; use Box<Integer>
  • No new T(): Cannot instantiate type parameter
  • No new T[]: Cannot create generic arrays
  • No static fields of type T: static T value; is illegal
  • No instanceof with parameterized types: obj instanceof List<String> is illegal (use obj instanceof List<?>)
  • Cannot extend Throwable: class MyException<T> extends Exception is illegal

Code Examples

Generic Class: Box<T>
class Box<T> {
    private T item;

    Box(T item) {
        this.item = item;
    }

    T getItem() {
        return item;
    }

    void setItem(T item) {
        this.item = item;
    }

    @Override
    public String toString() {
        return "Box[" + item + "]";
    }
}

public class Main {
    public static void main(String[] args) {
        Box<String> stringBox = new Box<>("Hello");
        Box<Integer> intBox = new Box<>(42);
        Box<Double> doubleBox = new Box<>(3.14);

        System.out.println(stringBox);
        System.out.println(intBox);
        System.out.println(doubleBox);

        // Type safety: compiler catches errors
        String s = stringBox.getItem();  // No cast needed
        int n = intBox.getItem();        // Auto-unboxing
        System.out.println("String: " + s);
        System.out.println("Int: " + n);

        // stringBox.setItem(123);  // Compilation error: type mismatch
    }
}
Box<T> can hold any type. When instantiated as Box<String>, only Strings are accepted. The compiler prevents type mismatches at compile time, and no casting is needed when retrieving values.
Box[Hello] Box[42] Box[3.14] String: Hello Int: 42
Generic Method: Working with Any Array Type
public class Main {
    // Generic method: <T> before return type
    static <T> T getMiddle(T[] array) {
        return array[array.length / 2];
    }

    static <T> void swap(T[] array, int i, int j) {
        T temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }

    static <T> String arrayToString(T[] array) {
        StringBuilder sb = new StringBuilder("[");
        for (int i = 0; i < array.length; i++) {
            if (i > 0) sb.append(", ");
            sb.append(array[i]);
        }
        return sb.append("]").toString();
    }

    public static void main(String[] args) {
        Integer[] ints = {10, 20, 30, 40, 50};
        String[] strs = {"Arjun", "Kavya", "Ravi"};

        System.out.println("Middle int: " + getMiddle(ints));
        System.out.println("Middle str: " + getMiddle(strs));

        System.out.println("Before swap: " + arrayToString(ints));
        swap(ints, 0, 4);
        System.out.println("After swap: " + arrayToString(ints));

        System.out.println("Before swap: " + arrayToString(strs));
        swap(strs, 0, 2);
        System.out.println("After swap: " + arrayToString(strs));
    }
}
Generic methods declare type parameters before the return type. The compiler infers T from the arguments. getMiddle works with Integer[] and String[] without separate implementations. swap swaps elements in any array type.
Middle int: 30 Middle str: Kavya Before swap: [10, 20, 30, 40, 50] After swap: [50, 20, 30, 40, 10] Before swap: [Arjun, Kavya, Ravi] After swap: [Ravi, Kavya, Arjun]
Bounded Type Parameters
class MathBox<T extends Number> {
    private T value;

    MathBox(T value) {
        this.value = value;
    }

    double sqrt() {
        return Math.sqrt(value.doubleValue());
    }

    boolean isPositive() {
        return value.doubleValue() > 0;
    }

    T getValue() {
        return value;
    }
}

public class Main {
    // Bounded generic method
    static <T extends Comparable<T>> T max(T a, T b) {
        return a.compareTo(b) >= 0 ? a : b;
    }

    public static void main(String[] args) {
        MathBox<Integer> intBox = new MathBox<>(16);
        MathBox<Double> dblBox = new MathBox<>(2.25);

        System.out.println("sqrt(16) = " + intBox.sqrt());
        System.out.println("sqrt(2.25) = " + dblBox.sqrt());
        System.out.println("16 positive? " + intBox.isPositive());

        // MathBox<String> strBox = new MathBox<>("Hi");  // Error: String not Number

        System.out.println("max(10, 20) = " + max(10, 20));
        System.out.println("max(A, Z) = " + max("A", "Z"));
        System.out.println("max(3.14, 2.71) = " + max(3.14, 2.71));
    }
}
T extends Number restricts T to Number and its subclasses. This allows calling Number methods like doubleValue(). The max method uses T extends Comparable<T> to ensure elements can be compared.
sqrt(16) = 4.0 sqrt(2.25) = 1.5 16 positive? true max(10, 20) = 20 max(A, Z) = Z max(3.14, 2.71) = 3.14
Wildcards: Unbounded, Upper, and Lower
import java.util.List;
import java.util.ArrayList;

public class Main {
    // Unbounded: read-only, any type
    static void printAll(List<?> list) {
        for (Object item : list) {
            System.out.print(item + " ");
        }
        System.out.println();
    }

    // Upper bounded: read Numbers (producer extends)
    static double sum(List<? extends Number> list) {
        double total = 0;
        for (Number n : list) {
            total += n.doubleValue();
        }
        return total;
    }

    // Lower bounded: write Integers (consumer super)
    static void addNumbers(List<? super Integer> list) {
        list.add(1);
        list.add(2);
        list.add(3);
    }

    public static void main(String[] args) {
        // Unbounded
        printAll(List.of("A", "B", "C"));
        printAll(List.of(1, 2, 3));

        // Upper bounded
        System.out.println("Sum of ints: " + sum(List.of(1, 2, 3)));
        System.out.println("Sum of doubles: " + sum(List.of(1.5, 2.5)));

        // Lower bounded
        List<Number> numList = new ArrayList<>();
        addNumbers(numList);
        System.out.println("Numbers added: " + numList);

        List<Object> objList = new ArrayList<>();
        addNumbers(objList);
        System.out.println("Objects added: " + objList);
    }
}
List<?> accepts any list (read-only). List<? extends Number> reads from any Number subtype (producer). List<? super Integer> writes Integers to any supertype list (consumer). This demonstrates the PECS principle.
A B C 1 2 3 Sum of ints: 6.0 Sum of doubles: 4.0 Numbers added: [1, 2, 3] Objects added: [1, 2, 3]
PECS: Producer Extends, Consumer Super
import java.util.List;
import java.util.ArrayList;

public class Main {
    // Copy from source (producer: extends) to dest (consumer: super)
    static <T> void copy(List<? extends T> source, List<? super T> dest) {
        for (T item : source) {
            dest.add(item);
        }
    }

    // Find max in a producer list
    static <T extends Comparable<T>> T findMax(List<? extends T> list) {
        T max = list.get(0);
        for (T item : list) {
            if (item.compareTo(max) > 0) {
                max = item;
            }
        }
        return max;
    }

    public static void main(String[] args) {
        List<Integer> ints = List.of(3, 1, 4, 1, 5);
        List<Number> nums = new ArrayList<>();

        copy(ints, nums);  // Integer -> Number
        System.out.println("Copied: " + nums);

        System.out.println("Max int: " + findMax(ints));
        System.out.println("Max string: " + findMax(List.of("Ravi", "Arjun", "Kavya")));
    }
}
The copy method reads from source (producer, uses extends) and writes to dest (consumer, uses super). findMax reads from a producer list. PECS is the key to writing flexible, type-safe generic methods.
Copied: [3, 1, 4, 1, 5] Max int: 5 Max string: Ravi
Generic Interface: Repository Pattern
import java.util.ArrayList;
import java.util.List;

interface Repository<T> {
    void save(T entity);
    T findById(int id);
    List<T> findAll();
    int count();
}

class Student {
    int id;
    String name;
    Student(int id, String name) { this.id = id; this.name = name; }
    @Override
    public String toString() { return "Student{" + id + ", " + name + "}"; }
}

class StudentRepository implements Repository<Student> {
    private List<Student> store = new ArrayList<>();

    @Override
    public void save(Student s) {
        store.add(s);
        System.out.println("Saved: " + s);
    }

    @Override
    public Student findById(int id) {
        for (Student s : store) {
            if (s.id == id) return s;
        }
        return null;
    }

    @Override
    public List<Student> findAll() { return store; }

    @Override
    public int count() { return store.size(); }
}

public class Main {
    public static void main(String[] args) {
        Repository<Student> repo = new StudentRepository();
        repo.save(new Student(1, "Arjun"));
        repo.save(new Student(2, "Kavya"));
        repo.save(new Student(3, "Ravi"));

        System.out.println("Find 2: " + repo.findById(2));
        System.out.println("Count: " + repo.count());
        System.out.println("All: " + repo.findAll());
    }
}
The Repository<T> interface defines a generic CRUD contract. StudentRepository implements it with Student as the type parameter. The same interface could be implemented for Course, Teacher, or any entity.
Saved: Student{1, Arjun} Saved: Student{2, Kavya} Saved: Student{3, Ravi} Find 2: Student{2, Kavya} Count: 3 All: [Student{1, Arjun}, Student{2, Kavya}, Student{3, Ravi}]
Type Erasure Demonstration
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        ArrayList<String> strings = new ArrayList<>();
        ArrayList<Integer> integers = new ArrayList<>();

        // At runtime, both are just ArrayList (type erasure)
        System.out.println(strings.getClass().getName());
        System.out.println(integers.getClass().getName());
        System.out.println(strings.getClass() == integers.getClass());

        // Cannot use instanceof with parameterized types
        List<?> list = new ArrayList<String>();
        System.out.println(list instanceof List);     // OK
        System.out.println(list instanceof List<?>);   // OK
        // System.out.println(list instanceof List<String>);  // Compilation error!

        // Generics provide safety at compile time, not runtime
        List rawList = strings;   // Raw type (no generics)
        rawList.add(42);          // No compile error with raw type!
        // String s = strings.get(0);  // ClassCastException at runtime!
        System.out.println("Raw type bypasses generic safety");
    }
}
At runtime, ArrayList<String> and ArrayList<Integer> are both just ArrayList (type parameters are erased). This is why you cannot use instanceof with parameterized types. Raw types bypass generic safety but risk runtime errors.
java.util.ArrayList java.util.ArrayList true true true Raw type bypasses generic safety

Common Mistakes

Using Primitive Types as Type Parameters

ArrayList<int> numbers = new ArrayList<>();  // Compilation error!
Box<double> box = new Box<>(3.14);            // Compilation error!
Compilation error: type argument cannot be a primitive type.
ArrayList<Integer> numbers = new ArrayList<>();  // Use wrapper class
Box<Double> box = new Box<>(3.14);                // Autoboxing handles conversion
Generics work only with reference types (objects), not primitive types. Use wrapper classes: Integer for int, Double for double, Boolean for boolean, etc. Java autoboxes and unboxes automatically.

Trying to Instantiate a Type Parameter

class Factory<T> {
    T create() {
        return new T();  // Compilation error!
    }
}
Compilation error: cannot instantiate the type T.
import java.util.function.Supplier;

class Factory<T> {
    private Supplier<T> constructor;

    Factory(Supplier<T> constructor) {
        this.constructor = constructor;
    }

    T create() {
        return constructor.get();
    }
}

// Usage: pass constructor reference
Factory<ArrayList> factory = new Factory<>(ArrayList::new);
ArrayList list = factory.create();
Due to type erasure, the JVM does not know what T is at runtime, so new T() is impossible. The workaround is to pass a Supplier<T> (a factory function) that creates the object.

Adding to a List<? extends T> (Producer Is Read-Only)

List<? extends Number> numbers = new ArrayList<Integer>();
numbers.add(42);  // Compilation error!
Compilation error: cannot add Integer to List<? extends Number>.
// Use ? extends for READING (producer)
List<? extends Number> producer = List.of(1, 2, 3);
Number n = producer.get(0);  // Reading is safe

// Use ? super for WRITING (consumer)
List<? super Integer> consumer = new ArrayList<Number>();
consumer.add(42);  // Writing is safe
List<? extends Number> could be a List<Integer>, List<Double>, or List<Number>. The compiler cannot guarantee that adding an Integer is safe (what if it is a List<Double>?). PECS: Producer Extends (read-only), Consumer Super (write-only).

Comparing Generic Types with == (Type Erasure Confusion)

ArrayList<String> a = new ArrayList<>();
ArrayList<Integer> b = new ArrayList<>();

// This is TRUE due to type erasure!
if (a.getClass() == b.getClass()) {
    System.out.println("Same class at runtime!");
}

// This does NOT compile
// if (a instanceof ArrayList<String>) { }
Logical error: thinking generic types are different at runtime. They are not.
// Use instanceof with unbounded wildcard
Object obj = new ArrayList<String>();
if (obj instanceof ArrayList<?>) {
    System.out.println("It is an ArrayList");
}
// Cannot determine if it is ArrayList<String> vs ArrayList<Integer> at runtime
Due to type erasure, ArrayList<String> and ArrayList<Integer> are the same class at runtime. You cannot use instanceof with parameterized types. Use instanceof ArrayList<?> (unbounded wildcard) instead.

Creating Generic Arrays

class Container<T> {
    T[] items = new T[10];  // Compilation error!
}
Compilation error: generic array creation.
class Container<T> {
    private Object[] items;  // Use Object array internally
    private int size = 0;

    @SuppressWarnings("unchecked")
    Container(int capacity) {
        items = new Object[capacity];
    }

    void add(T item) {
        items[size++] = item;
    }

    @SuppressWarnings("unchecked")
    T get(int index) {
        return (T) items[index];
    }
}
Generic arrays cannot be created because arrays carry type information at runtime (covariant), while generics are erased. The workaround is to use Object[] internally and cast when returning. The @SuppressWarnings("unchecked") annotation silences the compiler warning.

Summary

  • Generics allow writing classes, interfaces, and methods that work with any type while maintaining compile-time type safety. They were introduced in Java 5.
  • Generic classes use type parameters: class Box<T> { T value; }. Common conventions: T (Type), E (Element), K (Key), V (Value), N (Number).
  • Generic methods declare their own type parameters: static <T> T max(T a, T b). The type is inferred from arguments.
  • Bounded type parameters restrict types: <T extends Number> limits T to Number and its subclasses. Multiple bounds use &: <T extends Number & Comparable<T>>.
  • Wildcards (?) represent unknown types. Unbounded: List<?>. Upper bounded: List<? extends Number>. Lower bounded: List<? super Integer>.
  • PECS (Producer Extends, Consumer Super): Use extends when reading from a collection (producer), super when writing to it (consumer). This is the golden rule for wildcard usage.
  • Type erasure removes generic type information at runtime. ArrayList<String> and ArrayList<Integer> are both ArrayList at runtime. This limits generics: no new T(), no instanceof with parameterized types, no generic arrays.
  • Generics prevent ClassCastException by catching type errors at compile time. Without generics, every retrieval from a collection requires an unsafe cast.
  • Generic interfaces (Repository<T>) define type-safe contracts. Implementing classes specify the concrete type.
  • Restrictions: No primitive types (use wrappers), no new T(), no T[] creation, no static generic fields, no generic exceptions.
  • All Java collections (ArrayList<E>, HashMap<K,V>, HashSet<E>) use generics. Understanding generics is essential for using collections effectively.

Ready to Practice?

Test your understanding with 50+ practice questions on this topic.

Go to Practice Questions

Want to learn Java with a live mentor?

Explore our Java Masterclass