Java Generics — Complete Notes

Type parameters, bounds, wildcards, PECS and type erasure — everything you need to read and write modern Java confidently, explained from the problem they solve up to the tricks the compiler plays behind your back.

00. The problem generics solve

Generics let you write a class or method that works with any type, while still letting the compiler check types for you. In one line: they move type errors from runtime crashes to compile-time red squiggles.

Before generics (Java 4 and earlier), collections held plain Object. You could put anything in, but taking things out meant casting — and nothing stopped you casting wrongly:

Life before generics — casts and runtime blow-ups
List list = new ArrayList();      // holds Object
list.add("hello");
list.add(42);                     // compiles fine — no one's checking!

String s = (String) list.get(0);  // ok
String bad = (String) list.get(1);  // compiles, then CRASHES: ClassCastException at runtime
Same thing with generics — the bug is caught while typing
List<String> list = new ArrayList<>();
list.add("hello");
list.add(42);                     // COMPILE ERROR — 42 is not a String

String s = list.get(0);           // no cast needed, guaranteed to be a String
The two wins

Type safety — the compiler rejects the wrong type before your program ever runs. No casts — you read values out already typed, so the code is cleaner and self-documenting. A List<String> tells the reader (and the compiler) exactly what's inside.

A non-generic collection is a cardboard box labelled "STUFF." You can put anything in, but you never know what you'll pull out, so you check every time. A List<String> is a box labelled "BOOKS ONLY." The label lets everyone put in and take out books with total confidence — and refuses a football at the door.

01. Generic classes & interfaces

You make a type generic by giving it a type parameter — a placeholder, written in angle brackets, that stands in for a real type chosen later.

A generic Box that holds any one type
class Box<T> {                    // T is a type parameter — a placeholder
    private T value;
    public void set(T value) { this.value = value; }
    public T get() { return value; }
}

Box<String> sb = new Box<>();     // T becomes String here
sb.set("hi");
String x = sb.get();              // no cast

Box<Integer> ib = new Box<>();    // T becomes Integer here
ib.set(42);
// ib.set("nope");                // COMPILE ERROR

You can have several type parameters, and there's a naming convention worth knowing so you recognise it in library code:

Letter Conventionally means
T Type (the general one)
E Element (used all over the collections)
K, V Key, Value (maps)
N Number
R Return type
Two parameters — a key/value Pair
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 getKey() { return key; }
    public V getValue() { return value; }
}

Pair<String, Integer> age = new Pair<>("Asha", 30);
String name = age.getKey();       // String
int a = age.getValue();           // Integer, auto-unboxed to int
A generic interface

Interfaces take type parameters the same way — you've already used them: Comparable<T>, Iterator<E>, List<E>. When a class implements one, it fills in the type: class Person implements Comparable<Person>.

02. Generic methods

A single method can be generic even if its class isn't. You declare the type parameter before the return type, and the compiler usually infers it from the arguments so you rarely spell it out.

The <T> goes before the return type
class Util {
    // <T> declares the parameter; T is then used in params and return type
    public static <T> T firstOrNull(List<T> items) {
        return items.isEmpty() ? null : items.get(0);
    }

    public static <T> void swap(T[] arr, int i, int j) {
        T tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
    }
}

String s = Util.firstOrNull(List.of("a", "b"));   // T inferred as String
Integer n = Util.firstOrNull(List.of(1, 2, 3));   // T inferred as Integer
Where the <T> lives tells you which kind it is

On the class header (class Box<T>) → every instance is bound to one T. Just before a method's return type (static <T> T first(...)) → the method picks a fresh T on each call, independent of the class.

03. Bounded type parameters

Sometimes "any type" is too loose — you need "any type that can do X." A bound constrains T to a type or its subtypes, which also unlocks that type's methods inside your generic code.

<T extends Number> — now T is guaranteed to be a number
// Without a bound, we couldn't call doubleValue() — Object has no such method.
static <T extends Number> double sum(List<T> nums) {
    double total = 0;
    for (T n : nums) total += n.doubleValue();   // allowed BECAUSE of the bound
    return total;
}

sum(List.of(1, 2, 3));         // ok — Integer extends Number
sum(List.of(1.5, 2.5));        // ok — Double extends Number
// sum(List.of("a", "b"));     // COMPILE ERROR — String is not a Number
extends here means "is a subtype of"

In a bound, extends covers both class inheritance and interface implementation — so <T extends Comparable<T>> is valid even though Comparable is an interface. You can even require several at once with &: <T extends Number & Comparable<T>> means "a number that is also comparable."

04. Wildcards — the ?

A wildcard ? means "some specific type I'm not naming." It exists because of a surprising fact: List<String> is not a subtype of List<Object>, even though String is a subtype of Object. Generics are invariant.

Why generics are invariant (and must be)

If List<String> were a List<Object>, you could do this: assign it to a List<Object> variable, then add(42) — sneaking an Integer into a list of Strings. To prevent exactly that, the language forbids the assignment. Wildcards are how you regain flexibility safely.

? extends T — an upper bound (a "producer" you read from)

Accept a list of T or any subtype
// Reads numbers out; works for List<Integer>, List<Double>, List<Number>...
static double total(List<? extends Number> nums) {
    double t = 0;
    for (Number n : nums) t += n.doubleValue();   // reading as Number is safe
    return t;
}

total(List.of(1, 2, 3));       // List<Integer> ✓
total(List.of(1.5, 2.5));      // List<Double> ✓
// nums.add(1);                // NOT allowed — see below

With ? extends Number you can read elements as Number, but you cannot add anything (except null). Why: the list might really be a List<Integer>, and the compiler can't let you put a Double into it. So extends = read-only-ish, a producer of values.

? super T — a lower bound (a "consumer" you write into)

Accept a list of T or any supertype
// Writes Integers in; works for List<Integer>, List<Number>, List<Object>
static void addNumbers(List<? super Integer> dst) {
    for (int i = 1; i <= 3; i++) dst.add(i);      // adding Integer is safe
}

List<Number> nums = new ArrayList<>();
addNumbers(nums);              // ✓ — a List<Number> can certainly hold Integers
List<Object> objs = new ArrayList<>();
addNumbers(objs);              // ✓

With ? super Integer you can add Integers safely (any supertype list can hold them), but when you read, the best you get back is Object — because the list could be a List<Object>. So super = write-friendly, a consumer of values.

Unbounded ?

A bare List<?> means "a list of some unknown type." You can read elements as Object and check size(), but you can't add anything (except null). It's handy for methods that don't care about the element type, like printAll(List<?> list).

05. PECS — the rule that makes wildcards click

Producer Extends, Consumer Super. This one mnemonic tells you which wildcard to use every time.

  • If the parameter produces values for you (you read out of it), use ? extends T.
  • If the parameter consumes values from you (you write into it), use ? super T.
  • If it does both, use an exact type T (no wildcard).
PECS in the wild — a copy method (mirrors the JDK's Collections.copy)
static <T> void copy(List<? super T> dst,      // dst CONSUMES T  → super
                     List<? extends T> src) {  // src PRODUCES T  → extends
    for (T item : src) dst.add(item);
}

List<Number> dst = new ArrayList<>();
List<Integer> src = List.of(1, 2, 3);
copy(dst, src);                // ✓ read Integers out of src, write them into a Number list
Read it as a sentence

"I'm reading numbers out of src, so src is a producer → extends. I'm writing numbers into dst, so dst is a consumer → super." If you can say which direction the data flows, PECS picks the wildcard for you.

06. Type erasure — how generics actually work

Here's the twist that explains most of generics' quirks: they exist only at compile time. Once your code compiles, the type information is erased, and the bytecode looks much like the old pre-generics code.

The compiler uses your <T> to check your code, then throws the parameter away: unbounded T becomes Object (a bounded <T extends Number> becomes Number), and it silently inserts the casts you didn't have to write. This is why generics are said to be "syntactic sugar" over casting.

What the compiler sees vs. what the JVM runs
// You write:
Box<String> b = new Box<>();
String s = b.get();

// After erasure, the bytecode is effectively:
Box b = new Box();
String s = (String) b.get();      // the cast the compiler added for you

// And at runtime these are the SAME class:
List<String> a = new ArrayList<>();
List<Integer> c = new ArrayList<>();
System.out.println(a.getClass() == c.getClass());   // true — both are just ArrayList
Why Java did it this way

Erasure was chosen for backward compatibility: pre-generics code and generic code had to interoperate and run on the same JVM. The cost is that types aren't available at runtime — a limitation called being non-reified. Languages like C# reify generics; Java does not.

07. What erasure forbids

Almost every "you can't do that with generics" rule traces back to erasure: at runtime, T simply doesn't exist.

You can't… Because…
new T() the JVM doesn't know what T is at runtime
new T[10] same — can't create an array of an erased type
if (obj instanceof List<String>) the <String> is gone; only instanceof List<?> is allowed
use a primitive: List<int> type parameters must be reference types — use List<Integer>
a static field of type T T belongs to an instance, not the class
catch (MyException<T> e) exceptions can't be generic — the runtime can't tell them apart
The workaround for "new T()" — pass a factory or a Class
// Can't do: T t = new T();
// Instead, take a Supplier and let the caller decide how to build one:
static <T> List<T> filled(int n, Supplier<T> factory) {
    List<T> list = new ArrayList<>();
    for (int i = 0; i < n; i++) list.add(factory.get());
    return list;
}
List<StringBuilder> sbs = filled(3, StringBuilder::new);

08. Generics vs arrays — a real clash

Arrays and generics behave in opposite ways, and mixing them is a known sharp edge.

  • Arrays are covariant: String[] is an Object[]. This compiles — but can blow up at runtime.
  • Generics are invariant: List<String> is not a List<Object>. This is caught at compile time.
Arrays let a bug through that generics would have stopped
Object[] arr = new String[3];     // legal — arrays are covariant
arr[0] = 42;                       // compiles… then throws ArrayStoreException at runtime

List<Object> list = new ArrayList<String>();   // COMPILE ERROR — caught immediately (good!)
Don't create arrays of generic types

new List<String>[10] is illegal, and new T[10] can't be done. When you need a "generic array," use a List<T> instead — it's the idiomatic Java answer and avoids the whole minefield.

09. Type inference & the diamond

The compiler is good at figuring out type parameters so you don't repeat yourself.

Let the compiler fill it in
// The diamond <> (Java 7+): don't repeat the type on the right
Map<String, List<Integer>> m = new HashMap<>();   // not new HashMap<String, List<Integer>>()

// Method type inference — no need to write Util.<String>firstOrNull(...)
String s = Util.firstOrNull(List.of("a", "b"));

// var (Java 10+) — infers the whole variable type
var names = new ArrayList<String>();              // names is ArrayList<String>
The raw type trap

Writing List list = new ArrayList(); with no type parameter uses a raw type. It compiles (for backward compatibility) but disables all generic checking and earns "unchecked" warnings. Never use raw types in new code — always specify the type or use <>.

10. Gotchas — where Java surprises you

1. You can't overload on generic type alone.

void f(List<String>) and void f(List<Integer>) won't compile side by side — after erasure both are f(List), an identical signature.

2. A wildcard list is almost read-only.

You cannot add to a List<? extends T> (except null) — the compiler can't know the exact element type is compatible.

3. "Unchecked cast" warnings mean the compiler can't verify you.

Casting (List<String>) someRawList compiles with a warning because erasure means the cast can't actually be checked at runtime. Fix the types rather than suppressing the warning blindly.

4. Bounded T erases to its bound, not to Object.

<T extends Number> erases T to Number, so inside the method you can call Number's methods on a T.

11. Interview Q&A

Q: What is type erasure?

The compiler checks generic types, then removes the parameters from the bytecode — T becomes Object (or its bound) and casts are inserted automatically. Consequence: generic type info isn't available at runtime, and List<String> and List<Integer> are the same class. It was chosen for backward compatibility.

Q: Explain PECS.

Producer Extends, Consumer Super. Use ? extends T for a source you read from, ? super T for a destination you write to. It maximises how many types your API accepts while staying type-safe.

Q: Why can't you write new T() or new T[n]?

Because T is erased — at runtime there's no such type to instantiate. Work around it by passing a Supplier<T> or a Class<T>, and use a List<T> instead of a generic array.

Q: Difference between List<Object>, List<?> and a raw List?

List<Object> — a list you can add any object to. List<?> — a list of some unknown type; read as Object, can't add (except null). Raw List — no generics at all; compiles for legacy reasons but unsafe, avoid it.

12. Cheat sheet

  • Why: compile-time type safety + no casts.
  • Class: class Box<T> · method: static <T> T f(...) (declare <T> before the return type).
  • Bound: <T extends Number> — restricts T and unlocks that type's methods.
  • Wildcards: ? extends T = read (producer); ? super T = write (consumer); ? = unknown.
  • PECS: Producer Extends, Consumer Super.
  • Invariance: List<String> is not a List<Object>.
  • Erasure: generics vanish at runtime → no new T(), no new T[], no T.class, no primitives, no generic exceptions.
  • Arrays vs generics: arrays covariant (runtime-checked), generics invariant (compile-checked) — prefer List<T> over arrays.
  • Never use raw types in new code.
Last reviewed · July 2026 · part of knowledge-base