DEV Community

Yuriy F
Yuriy F

Posted on • Originally published at mydevnotes.hashnode.dev

Same named methods in Java. Part 2: When Generics Warp Overload Resolution

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);
        }
}
Enter fullscreen mode Exit fullscreen mode

Output:

m(T)
m(instance T)
Enter fullscreen mode Exit fullscreen mode

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; } 
}
Enter fullscreen mode Exit fullscreen mode

Method level — the type parameter exists only for that method:

class Utils { 
   static <T> T identity(T t) { return t; } 
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Which type variable substitutions make the call type-correct.

  2. 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); 
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 a Number ), so the unbounded version wins; the compiler chooses an intersection type that both Integer and String satisfy (e.g., Serializable & Comparable<?> & Constable & ConstantDesc).

  • For pick(1, 2), both overloads are applicable with T = Integer, and the bounded one is more specific, so it prints Bounded.

  • For Object n = 2; pick(1, n), the static types are Integer and Object; the bounded overload can’t accept Object (not a Number), but the unbounded one can infer T = 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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;}
}
Enter fullscreen mode Exit fullscreen mode

Are erased to :

static Number m(Number)
static Serializable m(Serializable)
static Comparable m(Comparable)
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 for Repo<Number> and also Repo<Serializable>: ? super Number means “Number or any supertype of Number,” and yes, interfaces countNumber implements Serializable, so Serializable qualifies.

  • <T extends Comparable<T>> m(Repo<T>, String) (“Extends”)

    Grabs Repo instances whose element type is self-comparable: Integer, Double, etc. Number and Serializable 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>
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode
  • m(1.0, 1)

    The “same-type” overload needs a single T that works for both arguments and that T must implement Comparable<T>. Double and Integer don’t share that one T, so the catch-all <T,U> takes the call.

  • m(1, 1)

    Both args are Integer, and Integer implements Comparable<Integer>. The same-type overload fits and is more specific than <T,U>.

  • m(a, b)

    Generics are invariant: List<Integer> and List<String> can’t both be List<T> with the same T. The same-lists overload is out; <T,U> wins.

  • m(c, d) where c and d are raw List

    Erasing element types makes both parameters look like plain List. That makes m(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)