Unit 27: Type Inference
Learning Objectives
After this unit, students should be able to:
- explain what type inference is and why Java supports it for generic methods and types
- identify the sources of type constraints (method arguments, bounds, and target typing) used during inference
- manually derive the inferred type arguments in common generic method calls
- recognize situations where type inference leads to surprising or unsafe behavior
- diagnose and explain compilation errors caused by incompatible inference bounds
Overview
In earlier units, we saw how Java’s type system helps prevent many classes of runtime errors by enforcing type correctness at compile time. We also learned that generic types and wildcards allow us to write flexible and reusable code—but often at the cost of additional type annotations.
To reduce verbosity, Java allows programmers to omit some type arguments and rely on the compiler to infer them automatically. This process, known as type inference, attempts to determine which type arguments would make the program type-correct.
While type inference can make code shorter and easier to read, it is not merely a convenience feature. It follows precise rules based on subtyping, bounds, and target types—and these rules can sometimes lead to results that surprise even experienced programmers.
In this unit, we study how Java infers type arguments for generic methods and types, how these inferences are derived from constraints, and why understanding the inference process is essential for writing safe and predictable generic code.
Diamond Operator
One example of type inference is the diamond operator <> when we new an instance of a generic type:
1 | |
Java can infer that p should be an instance of Pair<String,Integer> since the compile-time type of p is Pair<String,Integer>. The line above is equivalent to:
1 | |
Type Inferencing
We have been invoking
| contains v0.7 (with wild cards) | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 | |
by explicitly passing in the type argument Shape (also called type witness in the context of type inference).
1 | |
We could remove the type argument <Shape> so that we can call contains just like a non-generic method:
1 | |
and Java could still infer that S should be Shape. The type inference process looks for all possible types that match. In this example, the type of the two arguments must match. Let's consider each individually first:
- An object of type
Shapeis passed as an argument to the parameterobj. SoSmight beShapeor, if widening type conversion has occurred, one of the other supertypes ofShape. Therefore,Shape <: S <: Object. - A
Seq<Circle>has been passed intoSeq<? extends S>. A widening type conversion occurred here, so we need to find all possibleSsuch thatSeq<Circle><:Seq<? extends S>. This is true only ifSisCircle, or another supertype ofCircle. Therefore,Circle <: S <: Object.
Solving for these two constraints on S, we get the following:
1 | |
Therefore, S could be Shape or one of its supertypes: GetAreable and Object. We choose the lower bound, so S is inferred to be Shape.
Type inference can have unexpected consequences. Let's consider an older version of contains that we wrote:
| contains v0.4 (with generics) | |
|---|---|
1 2 3 4 5 6 7 8 9 10 | |
Recall that we want to prevent nonsensical calls where we are searching for an integer in an array of strings.
1 2 | |
But, if we write:
1 | |
The code compiles! Let's go through the type inference steps to understand what happened. Again, we have two parameters:
strArrayhas the typeString[]and is passed toT[]. SoTmust beStringor its superclassObject(i.e.String <: T <: Object). The latter is possible since Java array is covariant.123is passed as typeT. The value is treated asIntegerand, therefore,Tmust be eitherInteger, or its superclassesNumber, andObject(i.e.Integer <: T <: Object).
Solving for these two constraints:
1 | |
T can be is Object, so Java infers T to be Object. The code above is equivalent to:
1 | |
And our version 0.4 of contains actually is quite fragile and does not work as intended. We were bitten again by the fact that the Java array is covariant.
Type inference does not guarantee that the inferred type matches the programmer's intention. When multiple types satisfy the constraints, Java chooses the most general one that satisfies all bounds, even if that makes the method semantically meaningless. Explicit type witnesses override inference and can be used to document intent or avoid surprising inferences. However, they do not bypass type checking, only inference.
Target Typing
The example above performs type inference on the parameters of the generic methods. Type inference can involve the type of the expression as well. This is known as target typing. Take the following upgraded version of findLargest:
| findLargest v0.6 (with Seq<T>) | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
and we call
1 | |
We have a few more constraints to check:
- Due to target typing, the return type of
Tmust be a subtype ofShape(i.e.T <: Shape) - Due to the bound of the type parameter,
Tmust be a subtype ofGetAreable(i.e.T <: GetAreable) - Due to argument typing,
Seq<Circle>must be a subtype ofSeq<? extends T>, soTmust be a supertype ofCircle(i.e.Circle <: T <: Object)
Solving for all three of these constraints:
1 | |
The lower bound is Circle, so the call above is equivalent to:
1 | |
Further Type Inference Examples
We now return to our Circle and ColoredCircle classes and the GetAreable interface. Recall that Circle implements GetAreable and ColoredCircle inherits from Circle.
Consider the following method signature of a generic method foo:
1 | |
Then we consider the following code excerpt:
1 | |
What does the java compiler infer T to be? Let's look at all of the constraints on T.
-
First, the return type of
foomust be a subtype ofColoredCircle, thereforeT <: ColoredCircle. -
Tis also a bounded type parameter, thereforeT <: Circle. -
Our method argument is of type
Seq<GetAreable>and must be a subtype ofSeq<? extends T>, soTmust be a supertype ofGetAreable(i.e.GetAreable <: T <: Object).
We can see that there is no solution to our contraints, T can not be both a subtype of ColoredCircle and a supertype of GetAreable and therefore the Java compiler can not find a type T. The Java compiler will throw an error stating the inference variable T has incompatible bounds.
Lets consider, one final example using the following method signature of a generic method bar:
1 | |
Then we consider the following code excerpt:
1 | |
What does the java compiler infer T to be? Again, lets look at all of the constraints on T.
-
We can say that the return type of
barmust be a subtype ofGetAreable, thereforeT <: GetAreable. -
Our method argument is of type
Seq<Circle>and must be a subtype ofSeq<? super T>, soTmust be a subtype ofCircle(i.e.T <: Circle).
Solving for these two constraints:
1 | |
Whilst ColoredCircle is also a subtype of Circle it is not included in the above statement and therefore the compiler does not consider this class during type inference. Indeed, the compiler cannot be aware1 of all subtypes of Circle and there could be more than one subtype. Therefore T can only have the type Circle, so Java infers T to be Circle.
Rules for Type Inference
We now summarize the steps for type inference. First, we figure out all of the type constraints on our type parameters, and then we solve these constraints. If no type can satisfy all the constraints, Java will fail to compile. If in resolving the type constraints for a given type parameter T we are left with:
Type1 <: T <: Type2, thenTis inferred asType1Type1 <: T2, thenTis inferred asType1T <: Type2, thenTis inferred asType2
where Type1 and Type2 are arbitrary types. Java prefers the lower bound when both bounds are present, as it leads to more specific types and better type safety. If only one bound is present, Java uses that bound to infer the type.
Fresh Type Variables and Captured Wildcards
In more complex scenarios, Java may introduce fresh type variables or capture wildcards during type inference to handle cases where the exact type cannot be determined directly. These mechanisms allow Java to maintain type safety while still providing flexibility in generic programming. However, these topics are beyond the scope of this unit and will be covered in more advanced discussions on Java's type system.
-
Due to evolving specifications of software, at the time of compilation, a subtype may not have even been conceived of or written yet! ↩
-
Note that
T <: Objectis implicit here. We can see that this case could also be written asType1 <: T <: Object, and would therefore also be explained by the previous case (Type1 <: T <: Type2). ↩