Introduction to Generics

Generics let classes, interfaces, and methods operate on types specified by the caller, catching type mismatches at compile time instead of at runtime.

Generics, introduced in Java 5, let you parameterize a class, interface, or method with one or more types. Instead of writing ArrayList that stores plain Object and hoping callers put the right kind of value in, ArrayList<String> states — and the compiler enforces — that this particular list only ever holds String.

What Are Generics

A generic type or method declares one or more type parameters — placeholders like T, E, K, V — that are filled in with a concrete type when the generic is used. List<T> is a generic interface; List<String> is that interface used with T bound to String.

The compiler knows every element of names is a String, so it rejects adding an int and lets you retrieve elements without casting.

import java.util.ArrayList;
import java.util.List;

List<String> names = new ArrayList<>();
names.add("Alice");
// names.add(42);          // compile-time error: int cannot be converted to String

String first = names.get(0); // no cast needed

Why Generics Exist

Before generics (pre-Java 5), collections stored plain Object. Adding the wrong type compiled fine, and the mistake only surfaced as a ClassCastException at runtime, often far from where the bad value was inserted.

Without generics, the compiler cannot check element types; the error only appears as a ClassCastException when the wrong element is finally cast and used.

Generics move that class of error from runtime to compile time, which is strictly cheaper to fix and impossible to ship in the first place.

// Pre-generics style (legacy / raw type) — do not write new code like this
import java.util.ArrayList;
import java.util.List;

List rawList = new ArrayList();     // raw type, no type parameter
rawList.add("hello");
rawList.add(42);                    // compiles! no type checking

String s = (String) rawList.get(1); // runtime ClassCastException: Integer -> String

Compile-Time Type Safety

With a type parameter in place, the compiler checks every insertion and retrieval against the declared type argument, rejecting mismatches before the code ever runs.

List<Integer> scores = new ArrayList<>();
scores.add(95);
scores.add(88);
// scores.add("ninety");   // compile-time error

int total = 0;
for (int s : scores) {     // no cast needed — type is known
    total += s;
}

Avoiding Casts

A second, related benefit is that generics eliminate the explicit casts that raw-type code required on every read.

// Raw type: every get() needs a cast, and the cast can fail at runtime
List raw = new ArrayList();
raw.add("data");
String value1 = (String) raw.get(0);

// Generic type: no cast, and it's guaranteed correct by the compiler
List<String> typed = new ArrayList<>();
typed.add("data");
String value2 = typed.get(0);

Generic Classes (Preview)

A generic class declares one or more type parameters after its name, which can then be used inside the class as if they were real types.

T is a placeholder filled in at the point of use: String for stringBox, Integer for intBox. Generic classes are covered in depth in their own topic.

public class Box<T> {
    private T content;

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

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

Box<Integer> intBox = new Box<>();
intBox.set(42);

Generic Methods (Preview)

A method can introduce its own type parameter, independent of any class-level type parameter, so a single (often static) method can work across many types.

The <T> before the return type declares the method's own type parameter. Generic methods get their own dedicated topic with more detail.

public class Utils {
    public static <T> T firstElement(List<T> list) {
        return list.get(0);
    }
}

String s = Utils.firstElement(List.of("a", "b", "c")); // T inferred as String
Integer n = Utils.firstElement(List.of(1, 2, 3));       // T inferred as Integer

Type Parameter Naming Conventions

Java code conventionally uses single uppercase letters for type parameters, chosen to hint at their role.

T - Type (generic, general purpose)
E - Element (used heavily in collections, e.g. List<E>)
K - Key   (used in maps, e.g. Map<K, V>)
V - Value (used in maps, e.g. Map<K, V>)
N - Number
R - Result (often a method's return type)

Generic vs. Raw Types

A raw type is a generic type used without any type argument, e.g. List instead of List<String>. Raw types exist purely for backward compatibility with pre-Java-5 code and lose all compile-time type checking for that usage.

Raw types get their own dedicated topic; the short version is: don't use them in new code, they exist only for legacy interoperability.

List<String> parameterized = new ArrayList<>(); // preferred: type-checked
List raw = new ArrayList();                       // raw type: unchecked, avoid in new code

Where Generics Are Used Across the JDK

Generics are woven throughout the standard library, most visibly in the Collections Framework, but also in Optional, Comparable, Comparator, and functional interfaces like Function and Supplier.

import java.util.Optional;
import java.util.function.Function;

Optional<String> maybe = Optional.of("value");
Function<String, Integer> length = String::length;
int len = length.apply("hello"); // 5

Type Erasure (Preview)

At runtime, generic type parameters are largely erased: List<String> and List<Integer> share a single List class at the bytecode level for ordinary purposes. This is called type erasure and is covered in its own topic; it explains many of generics' quirks, like why you can't create a new T[] directly.

List<String> ls = new ArrayList<>();
List<Integer> li = new ArrayList<>();
System.out.println(ls.getClass() == li.getClass()); // true — both are ArrayList.class

Practical Usage

In everyday code, generics show up any time you declare a collection, write a reusable container or utility class, or write a method that should work identically across multiple types without duplicating logic.

import java.util.HashMap;
import java.util.Map;

Map<String, List<Integer>> groups = new HashMap<>();
groups.put("evens", List.of(2, 4, 6));
groups.put("odds", List.of(1, 3, 5));

Edge Cases

Generics cannot be parameterized with primitive types directly (List<int> doesn't compile) — wrapper types are used instead (List<Integer>), relying on autoboxing. Also, because of type erasure, you cannot check a generic type argument with instanceof at runtime, and you cannot create an array of a generic type directly.

List<Integer> nums = new ArrayList<>(); // OK — wrapper type
// List<int> bad;                       // compile-time error — no primitives

// if (nums instanceof List<Integer>) {} // compile-time error: cannot check parameterized type
if (nums instanceof List<?>) { }         // OK — unbounded wildcard is checkable

Mental Model

Think of a type parameter as a variable that holds a type instead of a value, filled in once when the generic class or method is used. The compiler substitutes it everywhere within that usage and checks every operation against it — turning what used to be a runtime guessing game ("what's really in this Object?") into a compile-time guarantee.

Quick Reference

Generic class:   class Box<T> { T value; }
Generic method:  static <T> T identity(T value) { return value; }
Usage:           Box<String> b = new Box<>();
Raw type (avoid): Box b2 = new Box();
Common names:    T, E, K, V, N, R
Benefit:         compile-time type checks + no casts
Limitation:      no primitives as type arguments; erased at runtime

Key Takeaways

Generics let classes, interfaces, and methods be parameterized by type, catching type mismatches at compile time instead of at runtime.

Generics eliminate the explicit casts that raw, pre-generics code required.

Type parameters use conventional single-letter names: T, E, K, V, N, R.

Generic type arguments must be reference types — primitives require their wrapper classes.

Type erasure removes most generic type information at runtime, which is why some operations (like instanceof against a parameterized type, or new T[]) are restricted; this is explored fully in the Type Erasure topic.

List list = new ArrayList(); list.add("a"); list.add(1); String s = (String) list.get(1); // ClassCastException at runtime

List<String> list = new ArrayList<>(); list.add("a"); // list.add(1); // compile-time error — caught immediately

Using a raw type discards compile-time checking, letting a type mismatch slip through to a runtime ClassCastException; a parameterized type catches it immediately at the add() call.

List<int> numbers = new ArrayList<>(); // does not compile List<Integer> numbers = new ArrayList<>(); // wrapper type required

Generic type arguments must be reference types; primitives cannot be used directly as type arguments — use the corresponding wrapper class.

List<String> list = new ArrayList<>(); if (list instanceof List<String>) { } // does not compile

List<String> list = new ArrayList<>(); if (list instanceof List<?>) { } // OK: unbounded wildcard

Because of type erasure, the JVM cannot check a specific parameterized type at runtime; only the unbounded wildcard form of instanceof is legal against a generic type.

What problem do generics solve compared to using raw Object-based collections?

They move type-mismatch errors from runtime (a ClassCastException discovered late, often far from the bug) to compile time, and they remove the need for explicit casts on every read.

Why can't you write List<int>?

Generic type arguments must be reference types. Primitives have no object representation at the type-argument position, so you must use the corresponding wrapper class, e.g. List<Integer>, relying on autoboxing to convert between int and Integer.

What are the conventional single-letter names for type parameters and what do they typically represent?

T for a general type, E for a collection element, K and V for a map's key and value, N for a number, and R for a method's result type.

What is a raw type, and why should new code avoid it?

A raw type is a generic type used without a type argument, e.g. List instead of List<String>. It disables compile-time type checking for that usage and exists only for backward compatibility with pre-Java-5 code.

Why can't you check obj instanceof List<String> at runtime?

Type erasure removes the specific type argument from the runtime representation of generic types, so the JVM has no List<String> type to check against at runtime — only the unbounded wildcard form List<?> is legal.

Interview: What compile-time guarantee does a generic collection give that a raw collection doesn't?

A generic collection guarantees, at every insertion point checked by the compiler, that only elements of the declared type argument (or its subtypes) can be added, so retrieval is guaranteed type-safe without a runtime cast.

Interview: Since generics are erased at runtime, in what sense is the type safety they provide real?

The safety is enforced entirely at compile time: the compiler rejects any code that would violate the declared type contract before it is ever compiled to bytecode, so a program that compiles cleanly with generics cannot suffer the class of ClassCastException that raw-type misuse can produce at the checked call sites.

Write a generic method <T> boolean isEmpty(List<T> list) that returns true if the list has no elements.

public static <T> boolean isEmpty(List<T> list) { return list.size() == 0; }

The method's own type parameter <T> lets it accept a List of any element type; the logic itself doesn't depend on T at all, only on list.size().

What does this print, and why does it compile despite mixing types?

Prints text then 99 on separate lines. It compiles because raw is a raw type with no type parameter, so the compiler performs no element-type checking at all — any Object can be added.

Write a generic class Pair<A, B> holding two values of possibly different types, with getters first() and second().

public class Pair<A, B> { private final A first; private final B second; public Pair(A first, B second) { this.first = first; this.second = second; } public A first() { return first; } public B second() { return second; } } // Usage: // Pair<String, Integer> p = new Pair<>("age", 30);

Two independent type parameters, A and B, let the pair hold two values of different (or the same) types, both fully type-checked at compile time with no casts needed to retrieve them.

What does this print, and what does it demonstrate about generics avoiding casts?

Prints 30. Because nums is declared List<Integer>, get(0) returns an Integer directly — no cast is needed to use it in arithmetic (it's unboxed automatically), demonstrating how a parameterized type removes the manual casting raw types would require.

List raw = new ArrayList();
raw.add("text");
raw.add(99);
for (Object o : raw) {
    System.out.println(o);
}
List<Integer> nums = new ArrayList<>();
nums.add(10);
nums.add(20);
Integer n = nums.get(0);
System.out.println(n + nums.get(1));