JAVA / GENERICS AND TYPE ERASURE
Designing generic APIs that stay readable
Decide where type parameters, wildcards, and named types belong in a Java signature so callers can read your generic API without decoding it.
What you will learn
- Drop a type variable that occurs once in a signature and write ? instead
- Keep wildcards in parameters and return concrete parameterized types
- Declare a type variable on the method when it varies per call, not on the class
- Stop at two type variables per signature; name a type instead of adding a third
Understanding Designing generic APIs that stay readable
A generic signature is the first documentation anyone reads, and every type variable in it is a question the reader has to answer: what is this, and what does it constrain? A type variable only answers that question when it appears in at least two places, because two occurrences state a relationship the compiler will then enforce, such as "the predicate must accept whatever this list holds" or "what comes back is what went in". A variable that appears exactly once constrains nothing at all; it is just a name for "some type", which is precisely what ? already means and says in one character.
Wildcards and type variables are not interchangeable across positions. A wildcard in a parameter widens what callers may pass, so it is generosity; a wildcard in a return type narrows what callers may do with the result, so it is a tax. Returning List<? extends Number> forces every caller to store the result in a wildcard-typed variable, forbids adding to it, and then leaks that wildcard into their own signatures, while returning List<Number> costs the implementation nothing.
The second design question is where a type variable lives. If the type is fixed for an object's lifetime it belongs on the class; if it changes from call to call it belongs on the method, which is why Optional<T> carries T but map declares its own R. Signatures also have a budget: two variables read fine when position makes their roles obvious (input and result, key and value), and a third usually signals a missing concept, better fixed by naming a record or interface than by adding another letter.
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
public class ReadableApi {
// T occurs three times, so it does real work: it ties the list's element
// type to what the predicate accepts and to what comes back.
static <T> Optional<T> firstMatching(List<? extends T> items, Predicate<? super T> test) {
for (T item : items) {
if (test.test(item)) {
return Optional.of(item);
}
}
return Optional.empty();
}
// A type variable here would occur once and constrain nothing,
// so it is spelled as a wildcard.
static int countNulls(List<?> items) {
int n = 0;
for (Object item : items) {
if (item == null) {
n++;
}
}
return n;
}
public static void main(String[] args) {
List<Integer> sizes = List.of(3, 8, 12, 5);
Optional<Number> firstBig = firstMatching(sizes, n -> n.intValue() > 7);
System.out.println("first over 7: " + firstBig.orElse(-1));
Optional<String> firstLong =
firstMatching(List.of("hi", "there", "everyone"), s -> s.length() > 4);
System.out.println("first long word: " + firstLong.orElse("none"));
System.out.println("nulls: " + countNulls(Arrays.asList("a", null, "c", null)));
}
}A type parameter earns its place only when it occurs at least twice and binds two positions together; anywhere else it should be a wildcard or a concrete type.
Worked examples
Wildcards in, concrete types out
Shows what a caller loses when a wildcard appears in the return type instead of a parameter.
import java.util.ArrayList;
import java.util.List;
public class ReturnTypes {
// The wildcard leaks: callers can only read from what they get back.
static List<? extends Number> squaresLoose(int n) {
List<Integer> out = new ArrayList<>();
for (int i = 1; i <= n; i++) {
out.add(i * i);
}
return out;
}
// A concrete parameterization: callers keep full use of the result.
static List<Number> squaresTight(int n) {
List<Number> out = new ArrayList<>();
for (int i = 1; i <= n; i++) {
out.add(i * i);
}
return out;
}
public static void main(String[] args) {
List<? extends Number> a = squaresLoose(3);
System.out.println("loose: " + a);
// a.add(4); would not compile: the element type is unknown
List<Number> b = squaresTight(3);
b.add(4.5);
System.out.println("tight: " + b);
}
}Example explained
Line 1squaresLoose really builds an ArrayList<Integer>, but its declared type reduces the caller's view to "some unknown subtype of Number".
Line 2The commented-out a.add(4) is rejected because the compiler cannot prove 4 belongs to that unknown element type, so the result is effectively read-only.
Line 3squaresTight returns List<Number>, which is why b.add(4.5) is accepted and 4.5 shows up in the printed list.
Line 4Both methods compute the same values, so the only difference the wildcard makes is a loss of caller freedom.
Class-level or method-level type variable
Demonstrates that a type that changes per call must be declared on the method, not on the class.
import java.util.function.Function;
public class VariableScope {
// R is fixed when the object is created, so one box serves one result type.
static class RigidBox<T, R> {
private final T value;
RigidBox(T value) {
this.value = value;
}
R apply(Function<? super T, ? extends R> f) {
return f.apply(value);
}
}
// R belongs to the call, so one box serves every result type.
static class Box<T> {
private final T value;
Box(T value) {
this.value = value;
}
<R> R apply(Function<? super T, ? extends R> f) {
return f.apply(value);
}
}
public static void main(String[] args) {
RigidBox<String, Integer> rigid = new RigidBox<>("hello");
Integer rigidLength = rigid.apply(String::length);
System.out.println("rigid: " + rigidLength);
// rigid.apply(String::toUpperCase); would not compile: R is already Integer
Box<String> box = new Box<>("hello");
Integer length = box.apply(String::length);
String upper = box.apply(String::toUpperCase);
System.out.println("box: " + length + " and " + upper);
}
}Example explained
Line 1RigidBox<String, Integer> pins R at construction, so the commented-out call returning a String is rejected even though the stored value has not changed.
Line 2Box.apply declares <R> itself, so R is chosen fresh at each call site and one Box<String> yields both an Integer and a String.
Line 3Function<? super T, ? extends R> is the same shape Optional.map uses: the function may accept a supertype of T and produce a subtype of R.
Line 4The second output line comes from a single box producing two result types, which is the practical test for where a type variable belongs.
Important notes
The "must occur twice" guideline is about a single method signature; a class-level parameter earns its place across the whole type, so it may legitimately appear once in some of its methods.
Single letters are fine while position makes the role clear (T subject, E element, K and V, R result); unrelated letters such as A and B send the reader back to the declaration on every call.
Common mistakes
Writing static <T> void printAll(List<T> list): T relates nothing to anything, so readers stop to look for a constraint that does not exist, when List<?> states the same contract.
Returning Map<String, ? extends Number> from a public method: callers cannot put anything into the result and must copy the wildcard into their own variables and signatures.
Declaring a per-call type on the class, as in class Box<T, R>: callers have to fix R at construction and build a separate object for every result type they want.
Try it yourself
Change, predict, then run
Declare Function<Number, String> f = n -> "n" + n; and try calling static <T, R> List<R> mapAll(List<T> in, Function<T, R> f) as mapAll(List.of(1, 2, 3), f). Change only the signature so the call compiles and the result is still a List<String>.
Open the Java workspaceCheck your understanding
In static <T> int countMatches(Collection<T> items, Predicate<? super T> test), the return type never mentions T. Why does T still earn its place?
- Without T the method could not accept a Collection<String>
- Erasure requires a type variable in any method that takes a functional interface
- T occurs twice, tying the collection's element type to what the predicate must accept
- A method returning a primitive cannot use wildcards in its parameters
Show answer
The two occurrences let the compiler check something it otherwise could not: that the predicate can handle whatever the collection holds. That link between two parameters cannot be expressed with wildcards alone, which is exactly why a type variable is justified even without appearing in the return type. Option 0 is tempting but false: Collection<?> accepts a Collection<String> just as happily; what it loses is the connection to the predicate.