TypeScript Function Overloads: A Practical Guide
AI generated
type
TypeScript · Functions
Using Function Overloads in TypeScript the Right Way
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.

10 min read TypeScript Type System API Design

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

11. FAQ: Function Overloads

1What is the difference between an overload signature and the implementation signature?
Overload signatures are the publicly visible, callable declarations without a function body. The implementation signature sits directly below them, contains the actual code, and must cover every parameter and return type combination declared by the overload signatures. From the outside only the overload signatures can be called, the implementation signature itself is not directly usable.
2Why can I not call the implementation signature directly?
TypeScript deliberately hides the implementation signature from the public interface. Only calls that match at least one of the overload signatures above it are accepted. This prevents callers from using argument combinations that would technically work inside the function body but were never meant to be part of the official API.
3How does TypeScript decide which overload signature applies to a call?
TypeScript checks overload signatures strictly in the order they appear in the source file. The first signature whose parameter count and parameter types match the arguments passed is used, even if a later signature would also match. The order in the code therefore directly determines the observable behavior of the function.
4What does an unreachable overload signature mean?
If a general signature, for example one typed with any or a wide union, is placed before a more specific one, the more specific signature is never selected, because the general one already catches every matching call first. TypeScript reports this as an error, since the later signature is effectively dead code.
5Can constructors of a class be overloaded too?
Yes, constructors follow the same pattern as methods. Several overload signatures for the constructor sit above the actual implementation, which covers every case. Constructor property promotion can only be used in the implementation signature, since only that one actually has a function body.
6When should I use overloads instead of union types?
Whenever the concrete input type clearly determines the concrete return type and that information matters to callers. A single signature built with union types always returns the full union as its type, regardless of the value actually passed, while overloads return the exact matching type for each input.
7When are generics or conditional types the better choice over overloads?
Once the relationship between input and return type follows the same pattern for an open ended set of types, it can usually be expressed as a generic formula with a conditional type. That avoids a growing list of individual overload signatures and stays correct for new types without any code changes.
8What does the this parameter do inside an overload signature?
A this parameter declared as the first pseudo argument types the expected call context of a function, for example the concrete DOM element a callback will later run on. It does not count toward the actual argument count at the call site, but it must be declared consistently across every overload signature.
9What do overloads look like in .d.ts declaration files?
Declaration files only contain the overload signatures themselves, without an accompanying implementation signature, since declaration files can never contain a function body. The actual implementation either exists as compiled JavaScript or is provided by the library the declaration describes.
10How many overload signatures make sense for a single function?
A good rule of thumb is two to four clearly distinguishable signatures. If the list grows well beyond that, the function becomes harder to read and maintain, and it is worth checking whether a generic or a conditional type can achieve the same type precision with less code.