Skip to main content
Java intermediate Lesson 21 of 58

Method Overloading in Java

Understand compile-time polymorphism through method overloading — parameter types, varargs, autoboxing pitfalls, and real-world builder patterns.

Method overloading is compile-time polymorphism — defining multiple methods with the same name but different parameter lists in the same class. The compiler selects the right version at compile time based on the arguments you pass. This lets you offer a clean, consistent API where the same operation — like format or send — works naturally with different input types, instead of forcing callers to use awkward names like formatInt or formatDouble.

Basic Overloading

The simplest use of overloading is providing a shorter convenience version of a method alongside the full version. Callers who only need the common case get a simpler call; those who need full control use the longer signature. The convenience version typically delegates to the full version to avoid duplicating logic.

public class StringUtils {

    // Different number of parameters
    public String repeat(String s, int times) {
        return s.repeat(times);
    }

    // Convenience version — delegates to the full version with a sensible default
    public String repeat(String s) {
        return repeat(s, 2);
    }

    // Different parameter types — same operation, different input formats
    public String format(int value) {
        return String.format("%d", value);
    }

    public String format(double value) {
        return String.format("%.2f", value);
    }

    public String format(boolean value) {
        return value ? "Yes" : "No";
    }

    public String format(String value) {
        return value == null ? "(none)" : value.trim();
    }
}

StringUtils utils = new StringUtils();
System.out.println(utils.repeat("ab"));         // abab
System.out.println(utils.repeat("ab", 3));      // ababab
System.out.println(utils.format(42));           // 42
System.out.println(utils.format(3.14159));      // 3.14
System.out.println(utils.format(true));         // Yes
System.out.println(utils.format("  hello  ")); // hello

Overloading with Varargs

Varargs (T...) let one method accept zero or more arguments of a type. Use varargs as the fallback overload — Java’s resolution rules prefer exact-match overloads first, then varargs. This means you can provide optimised two-arg and three-arg versions without breaking the general case.

public class Calculator {

    // Exact overload for two args — Java picks this when you pass exactly 2 ints
    public int sum(int a, int b) {
        System.out.println("two-arg version");
        return a + b;
    }

    // Exact overload for three args
    public int sum(int a, int b, int c) {
        System.out.println("three-arg version");
        return a + b + c;
    }

    // Varargs fallback: catches everything else (0, 4, 5, ... args)
    public int sum(int... values) {
        System.out.println("varargs version (" + values.length + " args)");
        int total = 0;
        for (int v : values) total += v;
        return total;
    }
}

Calculator calc = new Calculator();
System.out.println(calc.sum(1, 2));         // two-arg version → 3
System.out.println(calc.sum(1, 2, 3));      // three-arg version → 6
System.out.println(calc.sum(1, 2, 3, 4));   // varargs version (4 args) → 10
System.out.println(calc.sum());             // varargs version (0 args) → 0

Autoboxing Resolution — A Pitfall

Java resolves overloads in a specific order: exact match → widening → autoboxing → varargs. This means widening a primitive (int → long) takes priority over boxing it (int → Integer). If you’re not aware of this, the wrong overload can be called silently. Knowing the resolution order lets you predict and control which version gets picked.

public class OverloadPitfall {

    public void process(int value) {
        System.out.println("primitive int: " + value);
    }

    public void process(long value) {
        System.out.println("primitive long: " + value);
    }

    public void process(Integer value) {
        System.out.println("Integer (boxed): " + value);
    }
}

OverloadPitfall obj = new OverloadPitfall();
obj.process(42);          // exact match: "primitive int: 42"
Integer boxed = 42;
obj.process(boxed);       // exact match on Integer: "Integer (boxed): 42"

// If process(int) didn't exist, passing a literal 42 would call process(long)
// — widening beats autoboxing, so process(Integer) would NOT be chosen

Builder-Style Overloading

A common real-world pattern is to combine overloaded static factory methods with a full builder. The factory methods cover the 80% case with minimal syntax; the builder handles the remaining 20% with full control. This gives callers a clean API without forcing everyone to use the verbose builder for simple requests.

public class HttpRequest {

    private final String method;
    private final String url;
    private final Map<String, String> headers;
    private final String body;
    private final int timeoutMs;

    private HttpRequest(Builder builder) {
        this.method    = builder.method;
        this.url       = builder.url;
        this.headers   = Map.copyOf(builder.headers);
        this.body      = builder.body;
        this.timeoutMs = builder.timeoutMs;
    }

    // Overloaded static factory methods — simple, readable shortcuts for common cases
    public static HttpRequest get(String url) {
        return new Builder("GET", url).build();
    }

    public static HttpRequest post(String url, String body) {
        return new Builder("POST", url).body(body).build();
    }

    // Overload for POST with custom headers — same name, different signature
    public static HttpRequest post(String url, String body, Map<String, String> headers) {
        return new Builder("POST", url).body(body).headers(headers).build();
    }

    public static HttpRequest delete(String url) {
        return new Builder("DELETE", url).build();
    }

    @Override
    public String toString() {
        return String.format("%s %s (timeout=%dms, body=%s)", method, url, timeoutMs, body);
    }

    // Full builder for complex cases that the factory methods don't cover
    public static class Builder {
        private final String method;
        private final String url;
        private Map<String, String> headers = new HashMap<>();
        private String body = null;
        private int timeoutMs = 5000;

        public Builder(String method, String url) {
            this.method = method;
            this.url    = url;
        }

        public Builder body(String body)                     { this.body = body; return this; }
        public Builder headers(Map<String, String> headers)  { this.headers = headers; return this; }
        public Builder timeout(int ms)                       { this.timeoutMs = ms; return this; }
        public HttpRequest build()                           { return new HttpRequest(this); }
    }
}

// Simple cases use the clean overloaded factory methods
HttpRequest getReq  = HttpRequest.get("https://api.example.com/users");
HttpRequest postReq = HttpRequest.post("https://api.example.com/users", "{\"name\":\"Alice\"}");

// Complex case uses the full builder
HttpRequest complexReq = new HttpRequest.Builder("PUT", "https://api.example.com/users/1")
    .body("{\"name\":\"Alice\"}")
    .headers(Map.of("Authorization", "Bearer token123"))
    .timeout(10000)
    .build();

System.out.println(getReq);     // GET https://api.example.com/users (timeout=5000ms, body=null)
System.out.println(postReq);    // POST https://api.example.com/users (timeout=5000ms, body={"name":"Alice"})

Type Promotion Rules

When no exact-match overload exists, Java widens the argument type rather than narrowing it. Understanding the widening chain helps you predict which overload gets called when you pass a value whose type doesn’t exactly match any signature.

byte → short → int → long → float → double
public class WidenDemo {
    public void show(long value)   { System.out.println("long: "   + value); }
    public void show(double value) { System.out.println("double: " + value); }
}

WidenDemo d = new WidenDemo();
d.show(42);      // int widens to long (closer in the chain): "long: 42"
d.show(42L);     // exact match: "long: 42"
d.show(3.14f);   // float widens to double: "double: 3.140000104904175"
d.show(3.14);    // exact match: "double: 3.14"

Overloading for Null Safety

Overloaded methods can catch null explicitly in a controlled way. The key pitfall is ambiguity — if null could match multiple overloads, the compiler will refuse to compile. The cleanest fix is an Object overload that acts as the null-safe dispatcher.

public class MessageSender {

    public void send(String message) {
        System.out.println("Sending string: " + message);
    }

    public void send(byte[] data) {
        System.out.println("Sending binary: " + data.length + " bytes");
    }

    // Object overload handles null and dispatches to the correct specific overload.
    // Without this, send(null) would be a compile error — ambiguous between String and byte[].
    public void send(Object message) {
        if (message == null) {
            System.out.println("Sending null message — ignored.");
            return;
        }
        // Pattern matching routes to the right specific overload
        if (message instanceof String s) send(s);
        else if (message instanceof byte[] b) send(b);
    }
}

Frequently Asked Questions

Can you overload methods by return type only?
No. Java does not allow overloading based solely on return type. The compiler cannot distinguish which method to call when return type is the only difference.
What is autoboxing and how does it affect overload resolution?
Autoboxing is Java's automatic conversion between primitives and their wrapper types (int ↔ Integer). If an exact-match overload exists, Java uses it. If not, it tries widening first, then autoboxing, then varargs. This ordering can cause subtle resolution surprises.
What is the difference between overloading and overriding?
Overloading defines multiple methods with the same name in the same class, differentiated by parameter signatures. It is resolved at compile time. Overriding redefines a parent class method in a subclass with the same signature. It is resolved at runtime.