As we learned in Part 1 of this series, Don’t Underestimate Overloading, overloading is about guessing. The compiler is trying to guess which method to execute, based on the types of parameters you pass. Generics add another layer of guessing called type inference. That’s the compiler trying to guess the parameter types from the context before deciding which overload to pick. For this part, again, we’ll leave compilation errors aside — those are safe because the compiler and IDEs do a good job pointing them out right away. t is more dangerous when code compiles but a different method is called than expected. Or when a “harmless” signature change causes a different overload to be picked. We are also not going to touch inheritance - we will dive into it in future parts.
Let’s check this example we’ll review later:
import java.io.Serializable;
public class Teaser<T> {
<T extends Serializable> void m(T a) { System.out.println("m(T)"); }
void m(T a) { System.out.println("m(instance T)");}
public static void main(String[] args) {
new Teaser<Long>().m(1);
new Teaser<Integer>().m(1);
}
}
Output:
m(T)
m(instance T)
We can see two calls that look nearly identical end up taking different paths, all because of how generics, inference, and overloading interact.
Before we dive into inference and overload resolution, let us take a quick step back. Generics in Java are a way to parameterize types. Instead of writing the same code for String, Integer, List, etc., we use a type parameter, like T, and then the compiler substitutes it in.
Generics can be declared at two different levels:
Type level — the type parameter is attached to the whole type:
class Box<T> {
T value;
void set(T v) { value = v; }
}
Method level — the type parameter exists only for that method:
class Utils {
static <T> T identity(T t) { return t; }
}
To simplify, we’ll start with method-level generics only, since that is where inference and overload resolution play together most directly. Once we’ve covered how the compiler “guesses” method-level types, we’ll bring class-level generics back in and see how they make things even more complicated.
With generics involved, the compiler has to juggle two separate decisions at once:
Which type variable substitutions make the call type-correct.
Which overload is the most specific match once those substitutions are made.
Sometimes these guesses reinforce each other, and everything looks obvious. Other times, they clash, and you get outcomes that seem counter-intuitive. That is why two almost identical calls may resolve to different methods, or why a method that looks like a perfect candidate isn’t even considered applicable.
Generics in Java are tied to two separate compile-time processes. Both happen before your code ever reaches the JVM, but they serve very different purposes:
Type inference is how the compiler figures out which types to plug in when choosing a method. This guides overload resolution and ensures type safety without requiring you to spell out every
<T>
explicitly.Type erasure is what the compiler does after that choice, to generate valid bytecode for the JVM.
Let’s look at this example:
static <T> T pick(T a, T b) { return a; }
public static void main(String[] args) {
// infers T = Integer
var a = pick(1, 2);
// infers T = Comparable<? extends Object>
var b = pick(1, (Comparable<?>)2);
// infers T = Number & Constable & ConstantDesc & Comparable<? extends Number & Comparable<?> & Constable & ConstantDesc>
var c = pick(1, 2L);
}
The compiler guesses T
differently depending on context — that’s type inference. The method declaration, after compilation, is erased to:
pick(Object, Object)
Bounded generics add constraints to a type variable: T
must be a subtype (or an implementation) of some bound. The common form is an upper bound with extends
, e.g. <T extends Number>
, and you can have intersection bounds like <T extends Number & Comparable<T>>
. Bounds affect applicability (an overload with <T extends CharSequence>
can’t accept Object
) and often affect specificity (a bounded parameter is considered “tighter” than an unbounded <T>
when both are applicable). That’s why introducing or tightening a bound can silently change which overload the compiler picks, while everything still compiles.
In the example below:
static <T extends Number> void pick(T a, T b)
will be erased to:
void pick(Number, Number)
public class Example {
static <T> void pick(T a, T b) { System.out.println("Unbounded"); }
static <T extends Number> void pick(T a, T b) { System.out.println("Bounded"); }
public static void main(String[] args) {
// infers T = Serializable & Comparable<? extends Serializable & Comparable<?> & Constable & ConstantDesc> & Constable & ConstantDesc
pick(1, "a"); //Prints Unbounded
// infers T = Integer
pick(1, 2); //Prints Bounded
Object n = 2;
// infers T = Object
pick(1, n); //Prints Unbounded
}
}
In all three calls, inference first tries to find a single T
that fits both arguments; then overload resolution picks the most specific applicable method:
For
pick(1, "a")
, the bounded overload<T extends Number>
is not applicable ("a"
isn’t aNumber
), so the unbounded version wins; the compiler chooses an intersection type that bothInteger
andString
satisfy (e.g.,Serializable & Comparable<?> & Constable & ConstantDesc
).For
pick(1, 2)
, both overloads are applicable withT = Integer
, and the bounded one is more specific, so it prints Bounded.For
Object n = 2; pick(1, n)
, the static types areInteger
andObject
; the bounded overload can’t acceptObject
(not aNumber
), but the unbounded one can inferT = Object
, so it prints Unbounded.
But what if argument types are not available? Let’s look at another example:
public class NullPick {
static <U> U m(U u) { System.out.println("m(U)"); return u;}
static <U extends Number> U m(U u) { System.out.println("m(U extends Number)"); return u;}
public static void main(String[] args) {
//infers U = Integer
Integer s = m(null); //Prints m(U extends Number)
//infers U = Number
Object o = m(null); //Prints m(U extends Number)
}
}
For both calls, null
fits any reference type and is no help for the type inference. Both declared methods are applicable for both calls, i.e. can handle null. And m( U extends Number)
wins in both cases as a more specific method for the arguments.
But what if I add another call:
String s = m(null);
It causes a compilation error instead of a seemingly logical choice - the first method declared, “<U> U m(U u)
”. The reason is that m(U extends Number)
is still most specific, and cannot return String. And if I want this call to compile, I need to give an additional hint:
String t = NullPick.<String>m(null);
OR
String t = m((String)null);
Note that while the return type is inferred, it does not participate in overload resolution.
The type could be bound to at most one class and to multiple interfaces. If there is a class, it must appear first (leftmost). The erasure will use the leftmost type.
For example methods defined here:
public class Example {
static <T extends Number & Comparable<T> > T m(T a) { return a;}
static <T extends Serializable & Comparable<T>> T m(T a) { return a;}
static <T extends Comparable<T> & Serializable> T m(T a) { return a;}
}
Are erased to :
static Number m(Number)
static Serializable m(Serializable)
static Comparable m(Comparable)
But in this case, even though erasures are different, the compiler will not be able to pick between the last two methods, so both are unusable in practice.
Now let’s look at the example (we remove one of the ambiguous methods ):
public class Example {
static <T extends Number & Comparable<T>> void m(T a) { System.out.println("Number");}
static <T extends Comparable<T> & Serializable> void m(T a) { System.out.println("Serializable");}
public static void main(String[] args) {
m(1); //Prints Number
m(null); //Prints Number
m(""); //Prints Serializable
}
}
First two calls print Number
. The reason is Number
actually implements Serializable
, so it is the more specific candidate. The last call does not extend Number
, but it does implement Comparable
and Serializable
, which provides enough argument type information for the compiler to pick a method.
Now let’s see how adding Type level generics definitions affect the overload resolution. Check out the code below and compare it to NullPick example:
class X<T>{
<U extends Number> U m(U u){System.out.println("m(U extends Number)");return u;}
T m(T t){System.out.println("m(T)");return t;}
}
public class ClassLevelGeneric<T> {
public static void main(String[] args) {
X<Long> i = new X<>();
var a = i.m(1); //Prints m(U extends Number)
var b = i.m(null); //Prints m(T)
var c = i.m(1L); //Prints m(T)
double x = 'a';
var d = i.m(x); //Prints m(U extends Number)
}
}
Even though by erasure, <U extends Number> U m(U u)
is more specific than T m(T t)
we see that m(null)
and m(1L)
resolve to the T m(T t)
method.
With all that in mind, let’s take another look into the teaser from the beginning of the article:
import java.io.Serializable;
public class Teaser<T> {
<T extends Serializable> void m(T a) { System.out.println("m(T)"); }
void m(T a) { System.out.println("m(instance T)");}
public static void main(String[] args) {
new Teaser<Long>().m(1);
new Teaser<Integer>().m(1);
}
}
The first call passes an Integer, and out of two methods, one taking Long
and one taking something that extends Serializable
, only the Serializable
one is eligible.
The second call also passes an Integer. But now we have both methods eligible: one taking an Integer
and one taking a Serializable
. The Integer
one is more specific and wins.
One thing to keep an eye on — the T
in <T extends Serializable> void m(T a)
and the T
in Teaser<T>
are two different, absolutely unrelated type variables.
But what if we just do new Teaser().m(null)
? This way we have two methods, one expecting something that extends Serializable
, and one that takes Object
. Serializable
one is more specific and wins, printing m(T)
.
Now, let’s look at wildcards. A wildcard means “some unknown type” and depicted with “?”. You mostly see it as ? extends T
(a producer you read from) and ? super T
(a consumer you write to). They exist because generics are invariant: List<Integer>
is not a List<Number>
. Wildcards let you accept families of types safely. In Java, you most often see it all over the Collections API.
Example:
import java.io.Serializable;
class Repo<T>{ }
public class Demo{
static Repo<? super Number> m(Repo<? super Number> r, Integer a)
{ System.out.println("Wildcard");return r;}
static Repo<? extends Comparable<?>> m(Repo<? extends Comparable<?>> r, String a)
{ System.out.println("Extends");return r;}
public static void main(String[] args) {
Repo<Number> number = new Repo<>();
Repo<Serializable> serializable = new Repo<>();
Repo<Integer> integer = new Repo<>();
Repo<Double> dbl = new Repo<>();
var ir = m(number, null); //Prints Wildcard
var ic = m(serializable, null); //Prints Wildcard
var di = m(integer, null); //Prints Extends
var dr = m(dbl, null); //Prints Extends
}
}
Since Repo<? super Number>
and Repo<? extends Comparable<?>>
both erased to Repo
, we can have same-named methods handling each. So we did a little trick: we add a second argument and set it to null
explicitly in the method calls, so no type information could be inferred from it by the compiler. For this to work there should also be no relation between those arguments type, so neither is more specific for the null
constant.
What’s going on:
m(Repo<? super Number>, Integer)
(“Wildcard”)
Works forRepo<Number>
and alsoRepo<Serializable>
:? super Number
means “Number or any supertype of Number,” and yes, interfaces count—Number
implementsSerializable
, soSerializable
qualifies.<T extends Comparable<T>> m(Repo<T>, String)
(“Extends”)
Grabs Repo instances whose element type is self-comparable:Integer
,Double
, etc.Number
andSerializable
don’t meet that bound, so they fall back to the “Wildcard” method. Here you handle the precise element type (Repo<Integer>
,Repo<Double>
).
Here is a simple example of using wildcards with collections:
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
public class ExampleCollections {
void m(Collection<?> c) { System.out.println("Collection<?>"); }
void m(List<? extends Number> numbers) { System.out.println("List<? extends Number>"); }
public static void main(String[] args) {
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
new ExampleCollections().m(a); // Prints Collection<?>
new ExampleCollections().m(b); // Prints List<? extends Number>
}
}
And finally let’s take a look at another code snippet:
import java.util.ArrayList;
import java.util.List;
public class MultiType{
static <T extends Comparable<T>> void m(T t, T u)
{ System.out.println("Same type, extends Comparable");}
static <T, U> void m(T a, U b){System.out.println("Unbound types");}
static <T> void m(List<T> t, List<T> u){ System.out.println("Same lists");}
public static void main(String[] args) {
m(1.0, 1); //Prints Unbound types
m(1, 1); //Prints Same type, extends Comparable
List<Integer> a = new ArrayList<>();
List<String> b = new ArrayList<>();
m(a, b); //Prints Unbound types
List c = a;
List d = b;
m(c, d); //Prints Same lists
}
}
m(1.0, 1)
The “same-type” overload needs a singleT
that works for both arguments and thatT
must implementComparable<T>
.Double
andInteger
don’t share that oneT
, so the catch-all<T,U>
takes the call.m(1, 1)
Both args areInteger
, andInteger
implementsComparable<Integer>
. The same-type overload fits and is more specific than<T,U>
.m(a, b)
Generics are invariant:List<Integer>
andList<String>
can’t both beList<T>
with the sameT
. The same-lists overload is out;<T,U>
wins.m(c, d)
wherec
andd
are rawList
Erasing element types makes both parameters look like plainList
. That makesm(List<T>, List<T>)
applicable (you’ll get an unchecked warning), and it beats the<T,U>
version here.
Generic method overloading in Java boils down to a two-step guessing game: infer the types, then pick the most specific method. The tricky part is that inference, bounds, and specificity all influence each other, so tiny changes—swapping an argument type, tightening a bound, or just passing null—can completely flip which method gets called. While writing this article, I tested both Claude and GPT on picking the right method; they got roughly a third of the cases wrong, with total confidence. If AI struggles with this, you're in good company when it confuses you too.
Top comments (0)