12.1 Type Parameters and Generic Methods
After learning about classes, interfaces, collections, and exceptions, you can complete this Java 17 example in about 35 minutes. By the end, you should be able to declare generic classes, records, interfaces, and methods, and accurately express "what capabilities a type must have" using upper bounds.
Three containers return String, Part, and Ledger. Their structures are identical; only the element type differs. Copying three implementations is easy. Keeping all three consistent after every change is the expensive part.
Declaring everything as Object isn't ideal: callers would have to perform explicit casting every time they retrieve an item, and type mismatches wouldn't be caught until runtime. Generics take a different approach, making the type part of the declaration, with callers providing the type at usage time.
1. Raw type defers errors to runtime
A raw List does not constrain its element type. Import the relevant java.util types for these snippets. Compile the raw example separately: warnings and a runtime failure are expected. Keep different Box definitions separate too:
List raw = new ArrayList();
raw.add("Hammer");
raw.add(42);
String tool = (String) raw.get(1); // Runtime ClassCastExceptionErrors don't only occur on the last line. The real pollution point is integers being written into a list intended to hold only tool names. Parameterized types push diagnostics back to the write location:
List<String> tools = new ArrayList<>();
tools.add("Hammer");
// tools.add(42); // Compilation failedWhen migrating old code, don't use @SuppressWarnings("unchecked") to cover the entire class. First, identify the unsafe conversions, and keep the checks and suppressions to the smallest possible scope.
2. Declare Box<T> Once
One generic structure replaces the three repeated containers. The type parameter T represents the contained value, and each caller supplies the concrete type when using the container.
final class Box<T> {
private final T value;
Box(T value) {
this.value = java.util.Objects.requireNonNull(value, "value");
}
T value() {
return value;
}
}T is a type parameter; Box<String>'s String is a type argument. The compiler thus treats the return type of value() as String:
Box<String> toolBox = new Box<>("Hammer");
String tool = toolBox.value();The right <> is a diamond. The compiler infers types from the assignment target and constructor parameters, but inference does not mean "automatic identification at runtime." If the context is insufficient or overloaded constructors compete, explicit type arguments are still required.
3. Records and interfaces can also be parameterized
A two-part result can retain two independent types:
record Pair<K, V>(K key, V value) {
Pair {
java.util.Objects.requireNonNull(key, "key");
java.util.Objects.requireNonNull(value, "value");
}
}For Pair<String, Integer>, key() returns String, and value() returns Integer. A record generates constructors, accessors, and equality code without changing generic rules. It does not deep-copy components, so referenced component objects may still be mutable.
Interfaces can defer type selection to the implementation class:
interface Repository<ID, E> {
java.util.Optional<E> find(ID id);
void save(ID id, E entity);
}
final class ToolRepository implements Repository<String, String> {
private final java.util.Map<String, String> values = new java.util.HashMap<>();
@Override
public java.util.Optional<String> find(String id) {
java.util.Objects.requireNonNull(id, "id");
return java.util.Optional.ofNullable(values.get(id));
}
@Override
public void save(String id, String tool) {
values.put(java.util.Objects.requireNonNull(id, "id"),
java.util.Objects.requireNonNull(tool, "tool"));
}
}This repository rejects null IDs and values so Optional.empty means only “not found.” Saving the same ID replaces its value. The in-memory example provides neither persistence nor concurrent-write guarantees.
4. Static members cannot use the class type parameter T
Box<String> and Box<Integer> share the same static state. Therefore, type parameters of such a class cannot appear directly in static field or static method declarations:
final class Box<T> {
// static T lastValue; // Compilation failed
static <E> Box<E> of(E value) {
return new Box<>(value);
}
private final T value;
private Box(T value) { this.value = java.util.Objects.requireNonNull(value, "value"); }
T value() { return value; }
}The method of declares its own type parameter E, independent of the class parameter T. A method could reuse the same name and shadow T, but different names make their scopes easier to follow.
5. Declare generic method parameters before the return type
static <T> T first(java.util.List<T> values) {
if (values.isEmpty()) {
throw new IllegalArgumentException("empty values");
}
return values.get(0);
}When calling first(List.of("iron", "wood")), the compiler infers T to be String. Inference uses the declaration and call context, not guesses about the method body. First requires a non-null, nonempty list and returns its first element unchanged; that element may itself be null.
6. Upper Bound Explains "What This Type Must Be Able To Do"
Taking the maximum requires comparative ability:
static <T extends Comparable<? super T>> T max(java.util.List<T> values) {
java.util.Iterator<T> iterator = values.iterator();
if (!iterator.hasNext()) {
throw new IllegalArgumentException("empty values");
}
T result = java.util.Objects.requireNonNull(iterator.next(), "element");
while (iterator.hasNext()) {
T candidate = java.util.Objects.requireNonNull(iterator.next(), "element");
if (candidate.compareTo(result) > 0) result = candidate;
}
return result;
}Comparable<? super T> is wider than Comparable<T>: if the parent class already defines rules for comparing with the parent type, the child class list can also use them. Max requires non-null elements. Its Iterator makes one pass and avoids repeated indexed access, which would be quadratic on a LinkedList. This subclass inherits Comparable<Ranked> and can still be max's T:
class Ranked implements Comparable<Ranked> {
final int rank;
Ranked(int rank) { this.rank = rank; }
@Override public int compareTo(Ranked other) {
return Integer.compare(rank, other.rank);
}
}
final class SpecialRanked extends Ranked {
SpecialRanked(int rank) { super(rank); }
}
// Inside a method in the same class as max:
SpecialRanked best = max(java.util.List.of(
new SpecialRanked(2), new SpecialRanked(5)));
System.out.println(best.rank); // 5Multiple upper bounds are written as <T extends Base & Audited & Comparable<T>>, and if a class upper bound exists, it must be placed at the leftmost position.
7. Run the Basic Contract
Put the generic container, pair record, and "take the first item" method into one runnable example. The compiler rejects mismatched types, while assertions check runtime results; these checks address different failures.
First, prepare a temporary directory:
chapter12_basics_root=$(mktemp -d)
readonly chapter12_basics_root
mkdir -p "$chapter12_basics_root/out"
cd "$chapter12_basics_root"Save as GenericsBasicsDemo.java:
import java.util.List;
import java.util.Objects;
public class GenericsBasicsDemo {
public static void main(String[] args) {
Box<String> box = Box.of("Hammer");
checkEquals("Hammer", box.value());
Pair<String, Integer> stock = new Pair<>("iron", 12);
checkEquals("iron", stock.key());
checkEquals(12, stock.value());
checkEquals("iron", first(List.of("iron", "wood")));
checkEquals(9, max(List.of(3, 9, 5)));
expectIllegal(() -> first(List.of()));
System.out.println("Generic basics check passed");
}
static <T> T first(List<T> values) {
if (values.isEmpty()) throw new IllegalArgumentException("empty values");
return values.get(0);
}
static <T extends Comparable<? super T>> T max(java.util.List<T> values) {
java.util.Iterator<T> iterator = values.iterator();
if (!iterator.hasNext()) {
throw new IllegalArgumentException("empty values");
}
T result = java.util.Objects.requireNonNull(iterator.next(), "element");
while (iterator.hasNext()) {
T candidate = java.util.Objects.requireNonNull(iterator.next(), "element");
if (candidate.compareTo(result) > 0) result = candidate;
}
return result;
}
static void expectIllegal(Runnable action) {
try {
action.run();
throw new AssertionError("expected IllegalArgumentException");
} catch (IllegalArgumentException expected) {
// Empty input contract fails as expected.
}
}
static void checkEquals(Object expected, Object actual) {
if (!Objects.equals(expected, actual)) {
throw new AssertionError("expected=" + expected + ", actual=" + actual);
}
}
static final class Box<T> {
private final T value;
private Box(T value) { this.value = Objects.requireNonNull(value); }
static <E> Box<E> of(E value) { return new Box<>(value); }
T value() { return value; }
}
record Pair<K, V>(K key, V value) {
Pair {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(value, "value");
}
}
}javac --release 17 -encoding UTF-8 -Xlint:all -d out GenericsBasicsDemo.java
java -cp out GenericsBasicsDemoExpected output Generic basics check passed. Clean up after saving record:
cd
ls -ld -- "$chapter12_basics_root"
rm -r -- "$chapter12_basics_root"Check Each Type Parameter's Scope
Distinguish type parameters from type arguments, explain the source of diamond inference, and clarify why static methods must declare their own type parameters. Finally, provide an example where a parent class implements Comparable<Parent> and a child class inherits it, verifying the significance of Comparable<? super T>.
Next, let's tackle the most confusing part: why List<Integer> can't be directly passed to List<Number>, and what each of extends and super allows.
→ Next Step: Invariance, Wildcards, and PECS
Standard entry: JLS §4.5: Parameterized Types, JLS §8.4.4: Generic Methods.