Generics

Write once, use safely with any type — and understand what the compiler actually does

← Back to Index

What Are Generics?

Generics let you write classes, interfaces, and methods that work with any type while keeping full compile-time type safety. Instead of writing separate StringBox, IntegerBox, and UserBox classes, you write one Box<T> and the compiler generates a type-safe version for each use.

The problem they solve: before Java 5, collections stored raw Object references. You could put anything in a list — then crash at runtime when you retrieved it and the cast failed. Generics move that error to compile time, where it belongs.

// Before generics (Java 1.4): everything is Object — no safety
List names = new ArrayList();
names.add("Alice");
names.add(42);            // compiles fine — disaster waiting to happen
String s = (String) names.get(1);  // ClassCastException at runtime!

// With generics (Java 5+): the compiler is your safety net
List<String> names = new ArrayList<>();
names.add("Alice");
names.add(42);            // compile error: int is not a String
String s = names.get(0); // no cast needed — compiler guarantees it's a String
Type parameter naming conventions

By convention: T — general Type, E — Element (collections), K/V — Key/Value (maps), N — Number, R — Return type (functions). These are just conventions — the compiler accepts any identifier.

Generic Classes

Declare type parameters in angle brackets after the class name. Every use of T in the class is replaced by the actual type at the call site.

// A reusable container for any type — one class, infinite uses
public class Box<T> {
    private T content;

    public void    set(T content) { this.content = content; }
    public T       get()           { return content; }
    public boolean isEmpty()       { return content == null; }
}

Box<String>  stringBox = new Box<>();
stringBox.set("hello");
String s = stringBox.get();  // no cast — compiler knows it's String

Box<Integer> intBox = new Box<>();
intBox.set(42);
int n = intBox.get();        // auto-unboxing

Multiple type parameters

// Pair<K,V> — the same pattern Map uses internally
public class Pair<K, V> {
    private final K key;
    private final V value;

    public Pair(K key, V value) { this.key = key; this.value = value; }
    public K key()   { return key; }
    public V value() { return value; }

    // Static factory — cleaner than constructor with two same-type args
    public static <K, V> Pair<K, V> of(K k, V v) { return new Pair<>(k, v); }
}

Pair<String, Integer> nameAge  = Pair.of("Alice", 30);
Pair<Long, Boolean>   idActive = Pair.of(1001L, true);

Generic interfaces — the Repository pattern

// One interface, multiple entity types — no code duplication
public interface Repository<T, ID> {
    Optional<T> findById(ID id);
    List<T>       findAll();
    T              save(T entity);
    void           delete(ID id);
}

// Implementation binds the type parameters to concrete types
public class UserRepository implements Repository<User, Long> {
    @Override
    public Optional<User> findById(Long id) { return db.findUser(id); }
    // ... other methods
}

public class OrderRepository implements Repository<Order, Long> { ... }

// Spring Data JPA is exactly this pattern at scale:
// public interface UserRepository extends JpaRepository<User, Long> {}

Generic Methods

Methods can declare their own type parameters, independent of any class-level parameter. The <T> goes before the return type.

public class Collections2 {

    // T declared before return type — method-level type parameter
    public static <T> Optional<T> first(List<T> list) {
        return list.isEmpty() ? Optional.empty() : Optional.of(list.get(0));
    }

    // Two type parameters — converts between types
    public static <T, R> List<R> transform(List<T> list, Function<T, R> fn) {
        return list.stream().map(fn).collect(Collectors.toList());
    }

    // Bounded: T must be Comparable — gives access to compareTo()
    public static <T extends Comparable<T>> T max(List<T> list) {
        return list.stream().max(Comparator.naturalOrder()).orElseThrow();
    }
}

// Type inference — compiler figures out T from the argument
Optional<String> first = Collections2.first(List.of("a", "b"));

List<Integer> lengths = Collections2.transform(
    List.of("Alice", "Bob"),
    String::length          // T=String, R=Integer — inferred automatically
);

Integer biggest = Collections2.max(List.of(3, 1, 4, 1, 5));  // 5

Bounded Type Parameters

Bounds restrict which types can be used as type arguments AND give you access to the bound's methods inside the generic code.

// Upper bound: T must be Number or a subtype
// Unlocks Number's methods inside the class
public class NumericBox<T extends Number> {
    private final T value;

    public NumericBox(T value) { this.value = value; }

    public double doubled()    { return value.doubleValue() * 2; }  // Number method
    public boolean isPositive() { return value.doubleValue() > 0; }
}

NumericBox<Integer>    intBox = new NumericBox<>(5);      // ✅ Integer extends Number
NumericBox<BigDecimal> bigBox = new NumericBox<>(new BigDecimal("9.99")); // ✅
// NumericBox<String> bad = new NumericBox<>("x");  // ❌ compile error

// Multiple bounds: class first, then interfaces, separated by &
public static <T extends Number & Comparable<T>> T clamp(T value, T min, T max) {
    if (value.compareTo(min) < 0) return min;
    if (value.compareTo(max) > 0) return max;
    return value;
}

Integer result = clamp(150, 0, 100);  // 100 — Integer satisfies both bounds

Wildcards and PECS

Wildcards (?) appear in method parameters to accept collections of related types. They solve a key problem: List<Integer> is NOT a subtype of List<Number> even though Integer extends Number — generics are invariant.

// The invariance problem:
List<Integer> ints = List.of(1, 2, 3);
// sumList(ints);  ← compile error if sumList takes List<Number>

// Fix: wildcard accepts List<Integer>, List<Double>, List<Number>…
public static double sumList(List<? extends Number> list) {
    return list.stream().mapToDouble(Number::doubleValue).sum();
}
sumList(ints);    // ✅ works
sumList(List.of(1.5, 2.5));  // ✅ works — List<Double>

PECS — Producer Extends, Consumer Super

This is the single rule that governs wildcard choice:

/*
 *  ? extends T  →  you READ from the collection (it PRODUCES values)
 *  ? super T    →  you WRITE to the collection (it CONSUMES values)
 *  T (no wildcard) → you do BOTH
 *
 *  The canonical example: Collections.copy(dest, src)
 */
public static <T> void copy(
        List<? super T> dest,    // CONSUMER — we add T into dest
        List<? extends T> src) { // PRODUCER — we read T from src
    for (T item : src) dest.add(item);
}

List<Number>  dest = new ArrayList<>();
List<Integer> src  = List.of(1, 2, 3);
copy(dest, src);  // ✅

// Why you CANNOT add to ? extends:
public void cannotAdd(List<? extends Number> list) {
    // list.add(1);    ❌ — compiler doesn't know if it's List<Integer>, List<Double>…
    // list.add(1.0);  ❌ — same reason: unsafe
    Number n = list.get(0);  // ✅ reading is always safe
}

// Why you CANNOT read typed value from ? super:
public void cannotRead(List<? super Integer> list) {
    list.add(42);                  // ✅ writing Integer is safe
    Object o = list.get(0);      // ✅ but you only get Object back
    // Integer i = list.get(0);    ❌ — could be List<Number> or List<Object>
}
Wildcard Read? Write? Use when
List<T> ✅ as T ✅ T Both read and write
List<? extends T> ✅ as T Read-only (PRODUCER)
List<? super T> ✅ as Object ✅ T Write-only (CONSUMER)
List<?> ✅ as Object Unknown type, read as Object

Type Erasure — What the Compiler Actually Does

Java generics are implemented entirely at compile time via type erasure. The compiler checks your generic types, then strips them — the bytecode contains no generic information. This was a deliberate design choice for backward compatibility with pre-Java 5 bytecode.

/*
 *  YOUR CODE                      AFTER ERASURE (bytecode)
 *  ─────────────────────          ──────────────────────────
 *  List<String> list = ...        List list = ...
 *  String s = list.get(0);        String s = (String) list.get(0);
 *
 *  Box<Integer> box = ...         Box box = ...
 *  Integer i = box.get();         Integer i = (Integer) box.get();
 *
 *  <T extends Number> T max(...)  Number max(...)   ← bound becomes the erased type
 *  <T> T first(...)               Object first(...) ← no bound → Object
 */

// Consequence 1: List<String> and List<Integer> are the SAME class at runtime
List<String>  strings  = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
strings.getClass() == integers.getClass();  // true — both are ArrayList

// Consequence 2: instanceof with parameterized types is illegal
// if (obj instanceof List<String>) {}   // compile error
if (obj instanceof List<?>) {}          // ✅ wildcard works

// Consequence 3: cannot create instances or arrays of type parameters
// new T();     ❌ — JVM doesn't know what T is
// new T[10];   ❌ — arrays check types at runtime, generics don't

// Workaround for new T(): pass Class<T> and use reflection
public static <T> T newInstance(Class<T> clazz) throws Exception {
    return clazz.getDeclaredConstructor().newInstance();
}
// Or better: pass a Supplier<T> factory
public static <T> T create(Supplier<T> factory) { return factory.get(); }
User u = create(User::new);

Common Pitfalls

Raw types — never in new code
// ❌ Raw type: disables ALL type checking for this variable
List raw = new ArrayList();
raw.add("string");
raw.add(42);          // no compile error — disaster at runtime

// ✅ Always parameterize
List<String> typed = new ArrayList<>();
// typed.add(42);      compile error: caught before shipping
Heap pollution — mixing raw and parameterized types
// ❌ Heap pollution: a variable of parameterized type holds wrong-typed data
// Happens when you mix raw types and generics with unchecked casts
List<String> strings = new ArrayList<>();
List raw = strings;    // raw alias — no warning here
raw.add(42);           // ← heap pollution: Integer in a List<String>

String s = strings.get(0);  // ClassCastException at runtime — far from the cause

// Lesson: never assign a raw type alias to a parameterized variable.
// Enable -Xlint:unchecked in your build to catch all raw type warnings.
Generics and arrays don't mix
// ❌ Cannot create generic arrays — arrays are covariant, generics are invariant
// List<String>[] arr = new ArrayList<String>[10];  // compile error

// ✅ Use List of Lists instead
List<List<String>> lists = new ArrayList<>();

Interview Questions

🎓 Junior level

Q: What problem do generics solve?
Before Java 5, collections stored raw Object references — you could add anything, then crash with ClassCastException when retrieving. Generics move that error to compile time: the compiler rejects type mismatches before the code runs. They also eliminate the need for explicit casts when retrieving elements.

Q: What is the diamond operator <>?
Introduced in Java 7, it tells the compiler to infer the type arguments from context: new ArrayList<>() instead of new ArrayList<String>(). The type is inferred from the variable declaration on the left. Purely a convenience — no runtime effect.

Q: What is PECS?
Producer Extends, Consumer Super. When a collection produces values you read, use ? extends T. When a collection consumes values you write, use ? super T. Example: Collections.copy(List<? super T> dest, List<? extends T> src).

🔥 Senior level

Q: What is type erasure and what are its practical consequences?
The compiler checks generic types then removes them from the bytecode — replacing type parameters with their bounds (Object if unbounded, the bound class otherwise) and inserting casts where needed. Consequences: (1) List<String> and List<Integer> are the same class at runtime; (2) you cannot use instanceof with parameterised types; (3) you cannot create new T() or new T[]; (4) you cannot overload methods that differ only in generic type parameters — they erase to the same signature.

Q: Why are List<Integer> and List<Number> unrelated types, even though Integer extends Number?
Generics are invariant by design. If List<Integer> were a subtype of List<Number>, you could write: List<Number> nums = intList; nums.add(3.14); — putting a Double into a List<Integer>. That would break type safety silently. Arrays are covariant (and this exact bug is possible with arrays — that's why ArrayStoreException exists). Generics chose invariance to prevent it. Wildcards (? extends) restore flexibility when needed, with compile-time read/write restrictions to keep it safe.

Q: How do you instantiate a generic type T at runtime?
You can't use new T() because of type erasure — the JVM doesn't know what T is. Two clean solutions: (1) pass a Class<T> token and use clazz.getDeclaredConstructor().newInstance() — reflection-based, throws checked exceptions; (2) pass a Supplier<T> factory — type-safe, no reflection, no checked exceptions, preferred in modern code. Spring uses Class tokens extensively (e.g. RestTemplate.getForObject(url, User.class)).

Q: What is <T extends Comparable<? super T>>?
This is the bound used by Collections.sort(). The ? super T means T can be compared using a Comparable defined on T itself or on any supertype. Without the ? super, a class like LocalDate that implements ChronoLocalDate (which extends Comparable<ChronoLocalDate>, not Comparable<LocalDate>) wouldn't satisfy the bound. The ? super makes the bound flexible enough to cover this.