Engineering Notes
Home/Languages/Java/Generics
Languages · Java

Generics

Before generics (pre-Java 5), a reusable container had to store Object and force callers to cast on the way out — losing compile-time type safety entirely.

💻 Languages● Intermediate⏱ 7 min read🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • The Problem Generics Solve
  • Generic Classes
  • Generic Methods
  • Multiple Type Parameters
  • Bounded Type Parameters

1The Problem Generics Solve

Before generics (pre-Java 5), a reusable container had to store Object and force callers to cast on the way out — losing compile-time type safety entirely.

java
// pre-generics style
java.util.List raw = new java.util.ArrayList();
raw.add("hello");
raw.add(42); // compiles fine — no type checking at all
String s = (String) raw.get(1); // compiles, but throws ClassCastException at RUNTIME
java
// generic style
java.util.List<String> typed = new java.util.ArrayList<>();
typed.add("hello");
typed.add(42); // compile error — caught immediately, not at runtime

2Generic Classes

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

Box<String> stringBox = new Box<>("hello");
Box<Integer> intBox = new Box<>(42);

3Generic Methods

A type parameter can belong to a single method instead of the whole class, declared before the return type.

java
static <T> T firstOf(java.util.List<T> list) {
    return list.get(0);
}

String first = java.util.List.of("a", "b");
String result = firstOf(first); // T inferred as String

4Multiple Type Parameters

java
class Pair<A, B> {
    A first; B second;
    Pair(A first, B second) { this.first = first; this.second = second; }
}

Pair<String, Integer> entry = new Pair<>("age", 30);

5Bounded Type Parameters

extends on a type parameter restricts what T can be, and — critically — grants access to methods declared on the bound, which an unbounded T (implicitly bounded only by Object) doesn't have.

java
static <T extends Comparable<T>> T max(java.util.List<T> list) {
    T best = list.get(0);
    for (T item : list) {
        if (item.compareTo(best) > 0) best = item; // compareTo only exists because of the bound
    }
    return best;
}

max(java.util.List.of(3, 1, 4, 1, 5)); // 5

Multiple bounds are allowed with & (at most one class, plus any number of interfaces): <T extends Number & Comparable<T>>.

6Wildcards: ? extends and ? super (PECS)

Wildcards describe an unknown but constrained type argument for a variable's usage site, distinct from a type parameter declared at a class/method's definition site.

java
static double sumOf(java.util.List<? extends Number> list) { // can READ as Number; cannot add()
    double total = 0;
    for (Number n : list) total += n.doubleValue();
    return total;
}

sumOf(java.util.List.of(1, 2, 3));       // List<Integer> — accepted
sumOf(java.util.List.of(1.5, 2.5));      // List<Double> — also accepted

static void addNumbers(java.util.List<? super Integer> list) { // can WRITE Integer; reads only as Object
    list.add(1);
    list.add(2);
}

PECS — "Producer Extends, Consumer Super": use ? extends T for a structure you only read from (it produces T values for you), and ? super T for a structure you only write into (it consumes T values from you). This is why sumOf above can safely iterate the list as Number but could not call .add(...) on it — the compiler cannot guarantee which subtype of Number the underlying list actually holds, so allowing a write could insert the wrong subtype.

7Raw Types

Using a generic class without a type argument at all — List instead of List<String> — is legal for backward compatibility with pre-Java-5 code, but disables generic type checking for that usage entirely and produces "unchecked" compiler warnings.

java
java.util.List raw = new java.util.ArrayList<String>(); // raw type — legal, but a red flag
raw.add(42); // compiles with a warning; corrupts what was meant to be a List<String>

Never use raw types in new code — they exist purely for migrating old pre-generics codebases.

Source: Box<String> and Box<Integer>. Bytecode: both compile to the SAME class using Object, with a compiler-inserted checkcast at every read site
Source: Box<String> and Box<Integer>. Bytecode: both compile to the SAME class using Object, with a compiler-inserted checkcast at every read site
Under the HoodHow it works internally▾

Type erasure: generics are a compile-time-only construct. After the compiler finishes checking a generic class or method, it erases all type parameter information: T (unbounded) becomes Object, and T extends Number becomes Number, in the actual compiled bytecode. There is exactly one compiled .class file for Box<T>, used identically for Box<String> and Box<Integer> — unlike C++ templates, which generate separate compiled code per instantiation (monomorphization). At every point where erased code would otherwise lose type safety — e.g., reading a value back out of Box<String> — the compiler inserts an invisible checkcast bytecode instruction at the call site, so String s = stringBox.get(); really compiles to "call get(), which returns Object, then checkcast it to String." This is the exact mechanism that makes putting the wrong type into a raw-typed generic collection blow up later, far from where the bad add() call actually happened — the corrupted element sits harmlessly until some other piece of code tries to read it back out and the inserted checkcast fails.

Bridge methods: erasure creates real signature mismatches the compiler must paper over. Consider a class implementing Comparable<T>:

java
class MyBox implements Comparable<MyBox> {
    public int compareTo(MyBox other) { return 0; }
}

After erasure, the Comparable interface's method signature is compareTo(Object) (since its own T is erased to Object), but MyBox declares compareTo(MyBox) — these are two genuinely different signatures at the bytecode level, and MyBox would fail to actually implement the erased interface method. The compiler solves this by generating a synthetic bridge method, compareTo(Object o) { return compareTo((MyBox) o); }, invisible in source but present in the compiled class — it satisfies the interface's erased signature and delegates to the real, type-specific method via a cast. Bridge methods are a genuinely Java-specific consequence of combining erasure with polymorphic dispatch; they show up (as extra, synthetic entries) if you reflectively enumerate a generic class's methods, which surprises people who assume getDeclaredMethods() only returns what they wrote.

Restrictions that exist specifically because of erasure. You cannot write new T(), new T[], or x instanceof T for an unbounded type parameter T — at runtime, after erasure, there is no T left to instantiate, allocate an array of, or check against; the JVM would need to consult information that no longer exists in the compiled class. instanceof against a parameterized type like list instanceof List<String> is similarly illegal (only the raw list instanceof List is allowed) — for the same reason. This is a meaningfully different and more restrictive story than TypeScript's erasure: TypeScript erases to JavaScript, a dynamically-typed language with no static array-allocation-by-type step to begin with, so the "can't new T[]" restriction has no real TypeScript analog — it's a specifically Java (JVM-array-typing) consequence, not something erasure alone implies in every language.

⚠️ Common Pitfalls

  1. Trying to create a generic array (new T[10]) — illegal due to erasure (see Under the Hood); the usual workaround is (T[]) new Object[10] with an unchecked-warning suppression, or preferring a List<T> over an array entirely.
  2. Mixing raw types with generic types — List raw = someGenericList; compiles with only a warning, and any subsequent add() through the raw reference can silently corrupt the underlying, supposedly-typed collection.
  3. Using ? extends T where you need to write, or ? super T where you need to read back a specific type — violates PECS; the compiler will reject add() on a ? extends wildcard and reject anything but Object reads from a ? super wildcard, by design, not by accident.
  4. Assuming instanceof/reflection can recover the concrete type argument at runtime — list instanceof List<String> doesn't compile; erasure means the runtime object genuinely has no record of String as its element type once compiled.
  5. Forgetting a static context can't reference the enclosing class's type parameter — class Box<T> { static T value; } doesn't compile: static members belong to the class itself, not to any particular instantiation like Box<String>, and erasure means there's no single T a static member could consistently refer to.

📋 Quick Reference

java
class Box<T> { T value; }                          // generic class
static <T> T identity(T x) { return x; }             // generic method
<T extends Comparable<T>> T max(List<T> list) { }     // bounded type parameter

List<? extends Number> readable;   // producer — read as Number, cannot add()
List<? super Integer> writable;    // consumer — can add(Integer), read only as Object
// PECS: Producer Extends, Consumer Super

// erasure: Box<String> and Box<Integer> compile to ONE class using Object,
// with compiler-inserted checkcast at each typed read site.
// No new T(), no new T[], no `x instanceof ParameterizedType` — nothing left at runtime.

List rawList = new ArrayList<String>(); // raw type — legal but disables checking; avoid in new code

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1Why use generics?core▾

Compile-time type safety and no casts for collections and reusable code: List<String> prevents adding integers and returns Strings directly.

Follow-ups interviewers ask
  • Raw types? Legacy; avoid them.
Q2What is type erasure?core▾

Generic type information is removed at compile time; at runtime List<String> and List<Integer> are both List. You can't do new T(), instanceof List<String>, or create generic arrays directly.

Follow-ups interviewers ask
  • Workaround to keep type info? Pass Class<T> tokens or use super type tokens.
Q3Explain wildcards and PECS.core▾

? extends T (producer, read items as T), ? super T (consumer, write T items). PECS: Producer Extends, Consumer Super, as in Collections.copy(List<? super T> dst, List<? extends T> src).

Follow-ups interviewers ask
  • Unbounded wildcard? List<?> when the type doesn't matter.
Q4What are bounded type parameters?senior▾

<T extends Comparable<T>> T max(List<T> list) restricts T to comparable types, allowing calls to compareTo.

Follow-ups interviewers ask
  • Multiple bounds? <T extends Number & Comparable<T>>.
Q5Why are arrays covariant but generics invariant?core▾

Arrays check element types at runtime (ArrayStoreException); generics are erased, so the compiler enforces invariance to stay safe: List<Dog> is not a List<Animal>.

Follow-ups interviewers ask
  • Implication? Prefer lists to arrays in APIs.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.