JAVA / GENERICS AND TYPE ERASURE
Generic methods and inferring type arguments
Write methods that declare their own type parameters, and predict how javac solves for T from the argument types and the surrounding target type.
What you will learn
- Declare <T> before the return type so the type parameter belongs to the method
- Read a call site backwards: arguments and the assignment target both constrain T
- Assign to an explicitly typed variable when T appears only in the return type
- Reach for Util.<String>make() only when there is no target type to infer from
Understanding Generic methods and inferring type arguments
A generic method declares its own type parameter in angle brackets placed after the modifiers and immediately before the return type, as in static <T> List<T> listOf(T a, T b). That T exists only for one call: it is in scope in the parameter types, the return type, the throws clause and the body, and nowhere else. This is why a static method can be generic at all, since a class's type parameter is tied to an instance and static code cannot see it, while a method-level parameter has no such tie. An instance method inside a generic class can likewise add parameters of its own that are completely independent of the class's.
At each call site the compiler builds a small constraint problem rather than asking you for the type. Every argument contributes a lower bound, so passing a String where the parameter type is T means T must be String or something above it, and the context the call sits in contributes an upper bound, whether that is the variable being assigned or the parameter of an enclosing call. javac then picks the most specific type satisfying every bound, which is why listOf("a", 1) does not fail: String and Integer share supertypes, so T simply widens to one of them. All of this works from the declared types of the expressions, not from the objects they happen to hold, and it finishes before any bytecode exists.
Inference only has material to work with if T appears in the arguments or in the surrounding context. When T occurs solely in the return type and the call sits somewhere with no useful target, such as an argument to println or a receiver you immediately chain off, T resolves to its bound (Object when unbounded) and the code after it stops compiling. In that case you supply the type argument by hand with a qualified call like Util.<String>emptyBox(), which is also the way to override an inference result that came out narrower than you wanted. Prefer a generic method over a generic class whenever the type relationship only has to hold for the length of a single call.
import java.util.ArrayList;
import java.util.List;
public class GenericMethods {
// <T> sits before the return type: this parameter belongs to the method.
static <T> List<T> listOf(T first, T second) {
List<T> out = new ArrayList<>();
out.add(first);
out.add(second);
return out;
}
// T appears only in the return type, so no argument can pin it down.
static <T> List<T> emptyBox() {
return new ArrayList<T>();
}
// Two type parameters, each inferred on its own.
static <A, B> String join(A left, B right) {
return left + "|" + right;
}
public static void main(String[] args) {
List<String> words = listOf("ada", "grace"); // T = String, from the arguments
List<Integer> ports = listOf(80, 443); // T = Integer
System.out.println(words);
System.out.println(ports);
System.out.println(words.get(1).length()); // element type known, no cast
List<Double> ratios = emptyBox(); // T = Double, from the target type
ratios.add(0.25);
System.out.println(ratios);
System.out.println(join("port", 443));
System.out.println(join(443, 4.5));
}
}
A generic method owns its type parameter, and at every call site the compiler solves for that parameter from the argument types plus the type the result is expected to have.
Worked examples
An explicit type argument changes which overload runs
Pinning T by hand changes the compile-time type of the call, and therefore the overload chosen.
public class TypeWitness {
static <T> T id(T value) {
return value;
}
static void show(Object o) {
System.out.println("Object overload: " + o);
}
static void show(String s) {
System.out.println("String overload: " + s);
}
public static void main(String[] args) {
show(id("hi"));
show(TypeWitness.<Object>id("hi"));
}
}
Example explained
Line 1show(id("hi")): the argument "hi" bounds T below by String, so the call has type String and the more specific show(String) applies.
Line 2TypeWitness.<Object>id("hi"): no inference happens, the expression's static type is Object, and only show(Object) is applicable.
Line 3The object handed around is the same String in both lines; only the compile-time type differs, and that is what selects the method.
Line 4The explicit form needs the TypeWitness. qualifier, because a bare <Object>id("hi") is a syntax error.
A method parameter that is independent of the class parameter
Inside a generic class, a method can introduce a second type parameter that is inferred fresh at every call.
import java.util.ArrayList;
import java.util.List;
interface Mapper<F, T> {
T apply(F from);
}
class Box<E> {
private final List<E> items = new ArrayList<>();
void add(E item) {
items.add(item);
}
// R belongs to this method; E belongs to the class.
<R> List<R> mapWith(Mapper<E, R> mapper) {
List<R> out = new ArrayList<>();
for (E item : items) {
out.add(mapper.apply(item));
}
return out;
}
}
public class MethodOwnedTypes {
public static void main(String[] args) {
Box<String> box = new Box<>();
box.add("ada");
box.add("grace");
List<Integer> lengths = box.mapWith(s -> s.length());
List<Boolean> shortNames = box.mapWith(s -> s.length() < 4);
System.out.println(lengths);
System.out.println(shortNames);
}
}
Example explained
Line 1<R> List<R> mapWith(...): E is already fixed to String by the receiver Box<String>, while R is still open when the method is called.
Line 2box.mapWith(s -> s.length()): s is known to be String from E, the body's int result boxes to Integer, so R is solved as Integer.
Line 3The second call is the same method on the same object but yields R = Boolean, decided by the lambda body and the List<Boolean> target.
Line 4Neither call needs box.<Integer>mapWith(...), because the lambda and the assignment already constrain R.
Arguments of different types widen T
When two arguments bound the same parameter, T becomes a type that both of them fit into.
import java.io.Serializable;
public class SharedSupertype {
static <T> T pick(boolean useFirst, T a, T b) {
return useFirst ? a : b;
}
public static void main(String[] args) {
int n = pick(true, 10, 20);
System.out.println(n);
Number mixed = pick(false, 10, 2.5);
System.out.println(mixed);
Serializable alsoWorks = pick(true, "text", 7);
System.out.println(alsoWorks);
}
}
Example explained
Line 1int n = pick(true, 10, 20): both literals box to Integer, T is solved as Integer, and the returned Integer unboxes back to int.
Line 2Number mixed = ...: Integer and Double give two lower bounds and Number sits above both, so the call compiles instead of being rejected.
Line 3Serializable alsoWorks = ...: String and Integer share only Object among classes, but they also share Serializable and Comparable, and inference keeps those interfaces rather than collapsing to Object.
Line 4Nothing here inspects the runtime objects; every decision is made from the static types of the three arguments.
Important notes
An explicit type argument always needs a receiver: Util.<String>emptyBox() or this.<String>emptyBox(); the unqualified <String>emptyBox() does not parse.
A method's <T> shadows a class type parameter of the same name. It compiles, but the two are unrelated types, so pick a different letter for the method.
Common mistakes
Reusing the class's parameter in a static method: static E first(List<E> l) fails with "non-static type variable E cannot be referenced from a static context" — the method must declare its own <T>.
Assuming both arguments must have the same type: listOf("a", 1) compiles happily, T silently widens to a supertype shared by String and Integer, and the confusing "incompatible types" error appears later at the assignment instead of at the real cause.
Forgetting that inference uses declared types, not runtime values: after Object o = "hi", the call listOf(o, o) produces List<Object> and will not assign to List<String>, even though both elements really are strings.
Try it yourself
Change, predict, then run
Write static <T> List<T> repeat(T value, int times) that returns a list holding value that many times, and call it as repeat(7, 3) with no type argument. Then assign the same call to a List<Object> variable and confirm that the target type, not the argument, is what decided T.
Open the Java workspaceCheck your understanding
Given static <T> List<T> newList() { return new ArrayList<>(); }, why does List<String> names = newList(); compile without a type argument at the call site?
- newList returns a raw ArrayList and the assignment inserts a hidden cast to List<String>.
- The assignment target is part of the inference problem, so the compiler solves T as String before generating the call.
- T stays undecided until the first element is added at runtime, which then fixes it to String.
- T defaults to Object, and List<Object> is assignable to List<String> because generic types are covariant.
Show answer
There are no arguments, so the only constraint on T comes from the context the call sits in, and inference solves T = String with no cast and no warning. Option 4 is tempting because Object really is a valid value for T, but generic types are invariant, so List<Object> never assigns to List<String>; option 1 describes what a raw return type would force on you, which is precisely what declaring <T> avoids.