Multiple signatures, one implementation, precise types
Overloads let a single function expose several callable signatures while only one implementation exists behind the scenes. Used correctly they produce far more precise return types than a single signature built from union types, but they also come with their own pitfalls, from resolution order to unreachable signatures.
Table of Contents
- 1. The Problem: Input Type Determines Return Type
- 2. Basic Syntax: Overload Signatures and the Implementation Signature
- 3. Resolution Order: The First Matching Signature Wins
- 4. Overloads on Class Methods and Constructors
- 5. Overloads Versus Union Types and Generics
- 6. Common Pitfalls With Overloads
- 7. Overloads in .d.ts Declaration Files
- 8. Best Practices for Using Overloads
- 9. Comparing the Approaches and Conclusion
- 10. Summary
- 11. FAQ
1. The Problem: Input Type Determines Return Type
In many real functions, the return type depends directly on the type of one parameter. A parse function that returns an array for a string input and a single number for a numeric input cannot be described precisely with just one signature. If you simply use union types for the parameter and the return value, the caller loses the information about which input type maps to which output type, and has to add manual type checks or type assertions again.
This is exactly the case function overloading solves. An overloaded function consists of several overload signatures, each describing one concrete combination of parameter types and a return type, plus a single implementation signature that holds the actual function body. TypeScript checks which overload signature matches a given call and picks the return type accordingly, at zero runtime cost, since at runtime there is only ever one plain JavaScript function.
2. Basic Syntax: Overload Signatures and the Implementation Signature
An overloaded function consists of at least two overload signatures (declarations without a function body) followed directly by exactly one implementation signature that contains the actual body. The implementation signature must be general enough to cover every parameter and return type combination declared by the overload signatures above it, typically expressed with union types.
Crucially, the implementation signature itself is not visible from the outside and cannot be called directly. Callers only ever see the overload signatures. The example below returns an array of trimmed substrings for a string input and doubles a numeric input.
// Overload signatures (no function body)
function parseInput(value: string): string[];
function parseInput(value: number): number;
// Implementation signature (not callable directly, covers both cases)
function parseInput(value: string | number): string[] | number {
if (typeof value === "string") {
return value.split(",").map((part) => part.trim());
}
return value * 2;
}
const a = parseInput("1, 2, 3"); // type: string[]
const b = parseInput(42); // type: number
3. Resolution Order: The First Matching Signature Wins
TypeScript checks overload signatures in the order they are written, from top to bottom. As soon as a signature matches the number and types of the arguments passed, that signature is used for type checking the call, even if a later signature would also match. This ordering is not an implementation detail, it is part of the function's observable public contract.
The example below accepts either a single timestamp or a year, month and day combination. Since both signatures work with numeric arguments, only the number of arguments passed determines which signature applies, and the order in the source ensures the single timestamp case is checked first.
function createDate(timestamp: number): Date;
function createDate(year: number, month: number, day: number): Date;
function createDate(a: number, b?: number, c?: number): Date {
if (b === undefined) {
return new Date(a);
}
return new Date(a, b - 1, c ?? 1);
}
createDate(1723027200000); // matches the first signature
createDate(2026, 8, 7); // matches the second signature
4. Overloads on Class Methods and Constructors
Function overloading is not limited to standalone functions, it works the same way for class methods and constructors. The rule stays the same: several overload signatures stacked directly on top of each other, followed by exactly one implementation that covers every case. For constructors, keep in mind that constructor property promotion can only be used in the implementation signature, since only that one actually has a function body.
In practice this pattern fits classes with several meaningful initialization paths well, for example a simple constructor that only takes a base URL and an extended constructor that also accepts additional options.
class ApiClient {
constructor(baseUrl: string);
constructor(baseUrl: string, options: { timeoutMs: number });
constructor(
private baseUrl: string,
private options: { timeoutMs: number } = { timeoutMs: 5000 }
) {}
request(path: string): Promise<unknown>;
request(path: string, method: "GET" | "POST"): Promise<unknown>;
request(path: string, method: "GET" | "POST" = "GET"): Promise<unknown> {
return fetch(`${this.baseUrl}${path}`, { method }).then((res) => res.json());
}
}
const client = new ApiClient("https://api.example.com");
const timed = new ApiClient("https://api.example.com", { timeoutMs: 2000 });
5. Overloads Versus Union Types and Generics
Overloads are not the only way to model type dependent behavior. With a plain union type signature, the return type visible to the caller is always the full union, regardless of which concrete input type was actually passed. Generics combined with conditional types can restore that precision too, but they read and maintain much more abstractly.
As a rule of thumb, overloads work well when there is a small, fixed number of clearly distinct input to output combinations that cannot be expressed cleanly as a generic relationship. Generics and conditional types pay off once the relationship between input and return type genuinely holds for an open ended set of types and can be written as a formula.
// Option 1: union type, precision is lost
function toArrayUnion(value: string | number): string[] | number[] {
return typeof value === "string" ? value.split("") : [value];
}
const lostPrecision = toArrayUnion("abc"); // type: string[] | number[]
// Option 2: overloads, precision is preserved
function toArray(value: string): string[];
function toArray(value: number): number[];
function toArray(value: string | number): string[] | number[] {
return typeof value === "string" ? value.split("") : [value];
}
const precise = toArray("abc"); // type: string[]
// Option 3: conditional type, generic for arbitrary cases
type ToArrayResult<T> = T extends string ? string[] : number[];
function toArrayGeneric<T extends string | number>(value: T): ToArrayResult<T> {
return (typeof value === "string" ? value.split("") : [value]) as ToArrayResult<T>;
}
6. Common Pitfalls With Overloads
The most common mistake is trying to call the implementation signature directly from the outside. That does not work, because only the overload signatures above it form the public interface. A call with an argument combination that would work inside the implementation body but does not match any overload signature is rejected by TypeScript.
A second classic mistake is a wrong ordering, where a very general signature is placed before a more specific one. Since the first matching signature wins, the more specific signature can then never be reached, and TypeScript reports an error about an unreachable overload signature. In addition, overloads can carry an explicit this parameter as a first pseudo argument to type the expected call context, which is common in callback based browser or DOM APIs. That this parameter does not count toward the actual argument count, but it must be declared consistently across every overload signature.
// Wrong order: "any" catches everything, the second signature is unreachable
function format(value: any): string;
function format(value: number): string; // error: unreachable
function format(value: any): string {
return String(value);
}
// this parameter inside an overload signature
interface ButtonHandlers {
onClick(this: HTMLButtonElement, handler: () => void): void;
onClick(this: HTMLButtonElement, event: "click", handler: () => void): void;
}
// calling with a combination that matches no overload signature
function log(message: string): void;
function log(code: number, message: string): void;
function log(a: string | number, b?: string): void {
console.log(a, b);
}
log("Error", "extra"); // error: no matching overload signature
7. Overloads in .d.ts Declaration Files
Pure declaration files with a .d.ts extension never contain a function body, neither for plain functions nor for overloads. They only contain the overload signatures themselves, without an accompanying implementation signature, because the actual implementation already exists as compiled JavaScript or is provided by an external library.
This pattern shows up constantly in type definitions for JavaScript libraries, for example when a function returns different values depending on the number or type of arguments passed. For authors of their own libraries it is important that the overload signatures in the declaration file match the order and types of the overload signatures in the corresponding implementation file exactly, otherwise the public types and the actual runtime behavior drift apart.
// math-utils.d.ts
export declare function clamp(value: number, min: number, max: number): number;
export declare function clamp(value: number, range: [number, number]): number;
// usage in a .ts file that imports the declaration
import { clamp } from "./math-utils";
clamp(12, 0, 10); // first signature
clamp(12, [0, 10]); // second signature
8. Best Practices for Using Overloads
Overload lists should stay as short and as specific as possible, ideally two to four signatures. If the list grows well beyond that, it is often a sign that a generic or a conditional type could express the same relationship more cleanly and with less code to maintain. Each overload signature should also describe a clearly distinguishable combination of arguments, so resolution order does not become a source of bugs.
It also helps to consistently place more specific signatures before more general ones, keep the implementation signature as narrow as possible while still covering every case, and document each overload signature with its own comment. That way every caller sees in their editor exactly which argument combinations are allowed and what each one returns, without ever having to read the implementation.
/** Finds a single matching element in the DOM. */
function query(selector: string): Element | null;
/** Finds all matching elements in the DOM. */
function query(selector: string, all: true): NodeListOf<Element>;
function query(
selector: string,
all?: boolean
): Element | NodeListOf<Element> | null {
return all
? document.querySelectorAll(selector)
: document.querySelector(selector);
}
9. Comparing the Approaches and Conclusion
Overloads, union types, generics and conditional types all solve the same underlying problem in different ways: expressing a precise relationship between an input and a return value. Which approach fits best mainly depends on how many fixed cases exist and whether the relationship can be written as a genuinely generic formula.
For a small, fixed number of clearly separated cases, overloads are often the most readable solution, since editors present them as clean, individually documented signatures. Once the number of cases grows or the relationship between input and return type needs to hold for an open ended set of types, switching to generics or conditional types pays off, even though they can feel more abstract at first for developers new to the pattern.
| Approach | When it fits | Type precision | Maintenance cost |
|---|---|---|---|
Overloads |
Few, clearly separated input to output cases | Very high per case | Grows quickly with the number of signatures |
Union types |
Simple cases without a fixed type relationship | Low, caller must narrow manually | Low, but type information is lost |
Generics |
Behavior is structurally the same for any type | High, follows the concrete type parameter | Moderate, maintainable with a clear structure |
Conditional types |
Return type follows a formula based on input type | Very high, works for open ended type sets | Higher, requires more type system experience |
.d.ts overloads |
Typing an existing JavaScript library | High, without owning the implementation | Must stay in sync with the real implementation |
Mironsoft
TypeScript migration, type safety, and team onboarding
A JavaScript codebase without type safety, but no time for a full migration?
We migrate existing JavaScript projects to TypeScript step by step, set up strict compiler settings cleanly, and bring teams to the same type-safety level with code reviews and style guides.
Migration Roadmap
Plan and execute a gradual JS-to-TS migration without big-bang risk.
Strict Mode Rollout
Set up tsconfig.json, ESLint rules, and CI checks for lasting type safety.
Team Onboarding
Bring developers up to speed on TypeScript best practices with workshops and reviews.
10. Summary
Function Overloads
Core idea
Several overload signatures, one implementation signature, one return type per input type
Resolution
First matching overload signature from top to bottom wins
Biggest pitfall
Wrong ordering makes specific signatures unreachable
Alternative
For many or open ended cases, prefer generics or conditional types