Lesson 14 • Expert
Java Generics
After this lesson you'll write type-safe, reusable containers and methods — the same machinery that powers Java's collections — and you'll know exactly when to reach for a wildcard.
What You'll Learn in This Lesson
- ✓Write a generic class like
Box<T>that holds any type - ✓Write a generic method like
<T> T pick(...) - ✓Use multiple type parameters such as
Pair<K, V> - ✓Constrain a type with a bound:
<T extends Comparable<T>> - ✓Choose between
? extendsand? superusing PECS - ✓Explain type erasure and the limits it imposes
📚 Before You Start
You should already be comfortable with:
- Collections — using
ArrayListandHashMap - OOP & Inheritance — classes and type hierarchies
- Interfaces — implementing contracts like
Comparable
Real-World Analogy
Think of a generic class as a labelled shipping container. The container itself is the same design no matter what goes inside — but when you fill it, you slap a label on the door: "Books only" or "Glassware only."
The label is the type argument. A container labelled Box<Glass> will refuse a crate of books at the loading dock — the compiler is the dock worker checking the label. Without a label (a "raw type"), anything goes in, and you only discover the smashed glass when you open it later (a runtime crash). Generics move that check from opening the box (runtime) to loading the box (compile time).
1️⃣ Why Generics? The Problem
Before generics, collections held Object, so they accepted anything — and you had to cast on the way out. One wrong assumption and your program crashed at runtime.
// WITHOUT generics — dangerous!
List list = new ArrayList(); // a "raw type" — no label
list.add("Hello");
list.add(42); // no error — anything goes in
String s = (String) list.get(1); // RUNTIME CRASH: ClassCastException!
// WITH generics — the compiler protects you
List<String> list = new ArrayList<>();
list.add("Hello");
// list.add(42); // COMPILE ERROR — caught immediately!
String s = list.get(0); // no cast neededKey benefit: a bug caught at compile time is far cheaper than one your users find at runtime.
2️⃣ Generic Classes & Multiple Type Parameters
A generic class declares one or more type parameters in angle brackets after its name. Inside the class, those parameters act like real types; they get filled in when you create an instance.
public class Box<T> { // T is a placeholder for "some type"
private T content;
public void set(T item) { content = item; }
public T get() { return content; }
}
Box<String> nameBox = new Box<>(); // T becomes String here
nameBox.set("Alice");
String name = nameBox.get(); // no casting needed!
// TWO type parameters — a key and a value
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; }
}
Pair<String, Integer> entry = new Pair<>("Alice", 95);Naming convention: T = Type, E = Element, K = Key, V = Value, N = Number. The <> on the right is the "diamond" — Java infers the type, so you don't repeat it.
import java.util.Arrays;
public class Main {
// 1. Generic class — T is a placeholder, filled in when you create a Box.
static class Box<T> {
private T content;
void set(T item) { content = item; } // accepts only a T
T get() { return content; } // returns a T — no cast needed
@Override public String toString() { return "Box[" + content + "]"; }
}
// 2. Two type parameters: K (key) and V (value).
static class Pair<K, V> {
final K key;
final V value;
Pair(K key, V value) { this.key = key; this.value = value; }
@Override public String toString() { return "(" + key + " -> " + value + ")"; }
}
public static void main(String[] args) {
// A Box<String> can ONLY hold Strings.
Box<String> stringBox = new Box<>(); // <> is the "diamond" — Java infers String
stringBox.set("Hello Generics!");
String text = stringBox.get(); // no (String) cast required
System.out.println("String box: " + text);
// A Box<Integer> can ONLY hold Integers.
Box<Integer> numberBox = new Box<>();
numberBox.set(42);
int n = numberBox.get(); // unboxes straight to int
System.out.println("Number box: " + n);
// Pair<K, V> bundles two values of (possibly) different types.
Pair<String, Integer> alice = new Pair<>("Alice", 95);
Pair<String, Integer> bob = new Pair<>("Bob", 87);
System.out.println("Pair 1: " + alice);
System.out.println("Pair 2: " + bob);
// Generics make collections type-safe too.
Pair<String, Integer>[] scores = new Pair[]{ alice, bob };
System.out.println("All pairs: " + Arrays.toString(scores));
}
}String box: Hello Generics!
Number box: 42
Pair 1: (Alice -> 95)
Pair 2: (Bob -> 87)
All pairs: [(Alice -> 95), (Bob -> 87)]3️⃣ Generic Methods
A method can have its own type parameter, even inside a non-generic class. You declare it in angle brackets just before the return type. Java infers the actual type from the arguments you pass.
// the <T> here declares the parameter, before the return type
public static <T> void printArray(T[] array) {
for (T item : array) System.out.print(item + " ");
}
printArray(new String[]{"a", "b", "c"}); // T inferred as String -> a b c
printArray(new Integer[]{1, 2, 3}); // T inferred as Integer -> 1 2 3
// Returns a T — the caller gets back exactly the type they passed in
public static <T> T pick(T a, T b) {
return a != null ? a : b;
}4️⃣ Bounded Type Parameters
<T extends X> means "T must be X, or a subclass/implementer of X." This unlocks X's methods on T. (Note: for both classes and interfaces you write extends, never implements.)
// Only accepts Number or its subclasses (Integer, Double, ...)
public class NumberBox<T extends Number> {
private T value;
public NumberBox(T value) { this.value = value; }
public double doubleValue() { return value.doubleValue(); } // allowed!
}
NumberBox<Integer> intBox = new NumberBox<>(42); // OK
// NumberBox<String> bad = ...; // COMPILE ERROR — String isn't a Number
// Because T is Comparable, we may call compareTo inside the method
public static <T extends Comparable<T>> T findMax(T[] array) {
T max = array[0];
for (T item : array) if (item.compareTo(max) > 0) max = item;
return max;
}import java.util.List;
public class Main {
// Generic METHOD — the <T> before the return type declares the parameter.
// Works with an array of ANY type.
static <T> void printArray(T[] array, String label) {
StringBuilder sb = new StringBuilder(label + ": [");
for (int i = 0; i < array.length; i++) {
sb.append(array[i]);
if (i < array.length - 1) sb.append(", ");
}
System.out.println(sb.append("]"));
}
// <T> T pick — returns whichever argument is non-null.
static <T> T pick(T a, T b) {
return a != null ? a : b;
}
// BOUNDED: T must be Comparable, so the compiler lets us call compareTo.
static <T extends Comparable<T>> T findMax(T[] array) {
T max = array[0];
for (T item : array) if (item.compareTo(max) > 0) max = item;
return max;
}
// PECS — Producer Extends: we only READ Numbers out of the list.
static double sum(List<? extends Number> numbers) {
double total = 0;
for (Number x : numbers) total += x.doubleValue();
return total;
}
public static void main(String[] args) {
printArray(new Integer[]{1, 2, 3, 4, 5}, "Integers");
printArray(new String[]{"Alice", "Bob", "Charlie"}, "Strings");
System.out.println("pick(null, 7) = " + pick(null, 7));
System.out.println("max of [5,2,8,1,9] = " + findMax(new Integer[]{5, 2, 8, 1, 9}));
System.out.println("max of [c,a,z,b] = " + findMax(new String[]{"c", "a", "z", "b"}));
System.out.println("sum([1, 2, 3]) = " + sum(List.of(1, 2, 3)));
System.out.println("sum([1.5, 2.5, 3.5]) = " + sum(List.of(1.5, 2.5, 3.5)));
}
}Integers: [1, 2, 3, 4, 5]
Strings: [Alice, Bob, Charlie]
pick(null, 7) = 7
max of [5,2,8,1,9] = 9
max of [c,a,z,b] = z
sum([1, 2, 3]) = 6.0
sum([1.5, 2.5, 3.5]) = 7.5🎯 Your Turn #1 — A Generic Holder
Fill in the two blanks so Holder becomes a generic class that stores and returns a value of any type. Declare the type parameter, then give get() the right return type.
public class Main {
// 🎯 YOUR TURN — fill in the blanks marked with ___
// A simple generic container that remembers ONE value of any type.
static class Holder<___> { // 👉 declare a type parameter named T
private T value;
void set(T v) { value = v; }
___ get() { return value; } // 👉 this method returns a T
}
public static void main(String[] args) {
Holder<String> name = new Holder<>();
name.set("Ada");
Holder<Integer> year = new Holder<>();
year.set(1815);
System.out.println("Name: " + name.get());
System.out.println("Year: " + year.get());
// ✅ Expected output:
// Name: Ada
// Year: 1815
}
}🎯 Your Turn #2 — A Bounded min() Method
Complete the generic min method. It needs a Comparable bound so it can call compareTo, and the right comparison operator to pick the smaller value.
public class Main {
// 🎯 YOUR TURN — fill in the blanks marked with ___
// A generic method that returns the SMALLER of two values.
// It needs a Comparable bound so it can call compareTo.
static <T extends ___<T>> T min(T a, T b) { // 👉 the bound interface (think "compare")
return a.compareTo(b) ___ 0 ? a : b; // 👉 operator: negative means a is smaller
}
public static void main(String[] args) {
System.out.println("min(3, 9) = " + min(3, 9));
System.out.println("min(apple, kiwi) = " + min("apple", "kiwi"));
// ✅ Expected output:
// min(3, 9) = 3
// min(apple, kiwi) = apple
}
}5️⃣ Wildcards & PECS
A wildcard ? means "some specific but unknown type." It lets a method accept a whole family of generic types instead of one exact type.
List<?>— a list of something; you can read elements asObjectbut can't safely add.List<? extends Number>— a list of Number or a subtype; safe to read Numbers, can't add.List<? super Integer>— a list of Integer or a supertype; safe to add Integers, reads come back asObject.
PECS Rule: Producer Extends, Consumer Super. If the parameter produces values you read, use extends. If it consumes values you write, use super.
// PRODUCER — the list produces Numbers we read (extends)
public double sum(List<? extends Number> numbers) {
double total = 0;
for (Number n : numbers) total += n.doubleValue();
return total;
}
// CONSUMER — the list consumes Integers we write (super)
public void addIntegers(List<? super Integer> list) {
list.add(1);
list.add(2);
}? extends method you cannot add anything (except null) — the compiler doesn't know the exact subtype, so it can't guarantee your item fits. This is "wildcard capture," and it confuses everyone at first.6️⃣ Type Erasure — Under the Hood (and Its Limits)
Java generics are a compile-time feature only. After the compiler checks your types, it erases them — replacing each type parameter with its bound (or Object if unbounded) and inserting casts. At runtime the JVM has no idea what T was.
This is why List<String> and List<Integer> are literally the same class when the program runs. Erasure keeps generics backwards-compatible with pre-2004 Java, but it imposes real limits:
- •
new T()is illegal — the JVM doesn't know which constructor to call. - •
new T[10]is illegal — you can't create a generic array directly. - •
obj instanceof Tdoesn't work — there's noTat runtime to test against. - • You can't have two overloads that differ only by type argument (e.g.
f(List<String>)andf(List<Integer>)) — after erasure they're identical.
Workarounds: pass a Class<T> token to create instances, and prefer an ArrayList<T> over a raw generic array.
import java.util.ArrayList;
import java.util.List;
public class Main {
// A type-safe generic stack — works for any element type T.
static class Stack<T> {
private final List<T> items = new ArrayList<>();
void push(T item) { items.add(item); }
T pop() {
if (isEmpty()) throw new RuntimeException("Stack underflow!");
return items.remove(items.size() - 1); // returns a T, no cast
}
T peek() {
if (isEmpty()) throw new RuntimeException("Stack is empty!");
return items.get(items.size() - 1);
}
boolean isEmpty() { return items.isEmpty(); }
@Override public String toString() { return items.toString(); }
}
// Reuses Stack<Character> to validate balanced brackets.
static boolean checkBrackets(String str) {
Stack<Character> stack = new Stack<>();
for (char ch : str.toCharArray()) {
if (ch == '(' || ch == '[' || ch == '{') stack.push(ch);
else if (ch == ')' || ch == ']' || ch == '}') {
if (stack.isEmpty()) return false;
char open = stack.pop();
if ((ch == ')' && open != '(') ||
(ch == ']' && open != '[') ||
(ch == '}' && open != '{')) return false;
}
}
return stack.isEmpty();
}
public static void main(String[] args) {
// Same Stack class, a String element type.
Stack<String> undo = new Stack<>();
undo.push("Type Hello");
undo.push("Bold text");
undo.push("Add image");
System.out.println("Stack: " + undo);
System.out.println("Undo: " + undo.pop());
System.out.println("Top: " + undo.peek());
String[] tests = { "(a + b) * [c]", "((a + b)", "{[()]}", "([)]" };
for (String t : tests) {
System.out.println("'" + t + "' -> " + (checkBrackets(t) ? "valid" : "invalid"));
}
// The compiler stops you mixing types: undo.push(42) would NOT compile.
try {
new Stack<Integer>().pop();
} catch (RuntimeException e) {
System.out.println("Caught: " + e.getMessage());
}
}
}Stack: [Type Hello, Bold text, Add image]
Undo: Add image
Top: Bold text
'(a + b) * [c]' -> valid
'((a + b)' -> invalid
'{[()]}' -> valid
'([)]' -> invalid
Caught: Stack underflow!🧩 Mini-Challenge — Type-Safe Pair Printer
Time to write one from scratch. Read the brief in the comments, then fill in the body yourself — no blanks to lean on this time.
public class Main {
// 🎯 MINI-CHALLENGE: a type-safe Pair printer
// 1. Write a generic method: static <K, V> void printPair(K key, V value)
// that prints: key => value
// 2. Call it with ("score", 95) and ("active", true)
//
// ✅ Expected output:
// score => 95
// active => true
public static void main(String[] args) {
// your code here
}
}Common Errors (and the Fix)
❌ Raw types — "unchecked call to add(E)"
Writing List list = new ArrayList(); drops all type safety and triggers an "unchecked" warning. Always parameterise:
List list = new ArrayList(); // ❌ raw — anything goes in List<String> list = new ArrayList<>(); // ✅ type-safe
❌ "cannot instantiate the type T" — new T()
Type erasure means there's no T at runtime. Pass a factory or a class token instead:
T value = new T(); // ❌ won't compile
T value = clazz.getDeclaredConstructor()
.newInstance(); // ✅ clazz is a Class<T> you passed in❌ "generic array creation" — new T[10]
You can't create an array of a type parameter. Use a collection, or create an Object[] and cast (with a warning):
T[] arr = new T[10]; // ❌ generic array creation
List<T> arr = new ArrayList<>(); // ✅ preferred
@SuppressWarnings("unchecked")
T[] arr = (T[]) new Object[10]; // ✅ works, but cast is unchecked❌ "incompatible types" — adding to a ? extends list
This is wildcard-capture confusion. You can't add to a ? extends list because the exact subtype is unknown. Read from extends; write to super:
void f(List<? extends Number> nums) {
nums.add(1); // ❌ might be a List<Double> — 1 wouldn't fit
Number n = nums.get(0); // ✅ reading is always safe
}
void g(List<? super Integer> sink) {
sink.add(1); // ✅ Integer always fits a List<Integer-or-supertype>
}📋 Quick Reference
| Syntax | Example | Meaning |
|---|---|---|
| <T> | class Box<T> | Type parameter |
| <K, V> | Pair<String, Integer> | Multiple type params |
| <T> T m(...) | <T> T pick(T a, T b) | Generic method |
| <T extends X> | <T extends Comparable<T>> | Upper bound — unlocks X's methods |
| <?> | List<?> | Unknown type (read-only) |
| <? extends T> | List<? extends Number> | Producer — read values |
| <? super T> | List<? super Integer> | Consumer — write values |
Frequently Asked Questions
What is the difference between a generic class and a generic method?
A generic class declares its type parameter on the class itself, e.g. class Box<T>, and every instance is locked to one type such as Box<String>. A generic method declares its own type parameter just before the return type, e.g. static <T> T pick(T a, T b), so the type is chosen fresh on each call and the method can live in a non-generic class.
What does <T extends Comparable<T>> actually mean?
It is a bounded type parameter. It says T can be any type, but only one that implements Comparable<T>. Because the compiler now knows every T has a compareTo method, you are allowed to call item.compareTo(other) inside the method. Without the bound the compiler rejects that call, since a plain T has no such method.
When do I use ? extends versus ? super (PECS)?
Remember PECS: Producer Extends, Consumer Super. Use List<? extends Number> when the list produces values you only read out (you can read, but cannot add). Use List<? super Integer> when the list consumes values you write in (you can add Integers, but reads come back as Object). If you both read and write, use a normal named type parameter instead of a wildcard.
Why can't I write new T() or new T[10] inside a generic class?
Because of type erasure: the compiler removes generic type information after checking it, so at runtime there is no T to instantiate. To create instances, pass a Class<T> token and call clazz.getDeclaredConstructor().newInstance(), or accept a factory/Supplier. For arrays, create an Object[] and cast, or better, use an ArrayList<T> instead of a raw generic array.
Do generics make my program run faster or use less memory?
No. Generics are purely a compile-time feature. After erasure, List<String> and List<Integer> are the exact same class at runtime. Generics give you compile-time type safety and remove manual casts, which means fewer bugs and cleaner code — but the bytecode and runtime cost are the same as the old raw-type version.
🎉 Lesson Complete!
You can now write generic classes (Box<T>, Pair<K, V>) and generic methods (<T> T pick(...)), constrain them with bounds, choose wildcards using PECS, and explain why type erasure forbids new T() and generic arrays. This is the exact toolkit behind Java's collections and APIs.
Next up: Advanced Methods — overloading, varargs, method references, and recursion.
Sign up for free to track which lessons you've completed and get learning reminders.