36.5 Function Overloads: Multiple Signatures for a Single Implementation
Alright, let’s talk about function overloads, the TypeScript feature that lets you wear multiple hats without changing your head. You know how in JavaScript, a single function can return different types of things based on what you pass in? Think of parseInt(string) versus parseInt(string, radix). Overloads are how we teach TypeScript to understand that same flexibility, giving a single implementation multiple, precise type signatures.
The core idea is simple: you list the different ways someone can call your function, and then you write one implementation that handles all of them. The secret sauce, and the part everyone screws up at least once, is that the implementation’s signature must be compatible with all the declared overloads. It’s like a bouncer at a club; the implementation has to be ready to handle anyone the overloads let in.
The Anatomy of an Overload
Here’s the classic structure. You write your overload signatures first, followed by the actual implementation.
// Overload signatures - these are what the outside world sees
function greet(person: string): string;
function greet(persons: string[]): string[];
// Implementation signature - this is the grunt work inside the club
function greet(personOrPersons: unknown): unknown {
if (typeof personOrPersons === 'string') {
return `Hello, ${personOrPersons}!`; // string
} else if (Array.isArray(personOrPersons)) {
return personOrPersons.map(person => `Hello, ${person}!`); // string[]
}
// The implementation must account for all overloads, so we need this.
throw new Error('Unable to greet');
}
// Now, TypeScript's inference is beautifully precise:
const singleGreeting = greet('Alice'); // type: string
const multipleGreetings = greet(['Bob', 'Carol']); // type: string[]
Notice how the implementation uses unknown for its parameters and return type. This is the safest way. We have to be broader in the implementation because we’re handling all the cases. The type narrowing inside the function (typeof, Array.isArray) is what ensures we’re actually returning what we promised in the overloads.
Why You Can’t Just Use a Union
“Why not just use a union type for the parameter and return a union?” I hear you ask. Excellent question. You absolutely could for a simple case, but you lose the critical link between input and output.
// The weak way - loses the correlation
function weakGreet(personOrPersons: string | string[]): string | string[] {
// ... same impl
}
const result = weakGreet(['Dave']); // type: string | string[]
// Is result a string or an array? Who knows! You'd need to check.
Overloads exist to preserve that relationship. You tell TypeScript, “If you call me with this, you’ll get that back.” It’s a contract. A union is a maybe-this-maybe-that mush. Overloads are precise.
The Implementation is Your Responsibility
Here’s the trap. TypeScript only checks the types of the implementation against the overloads. It does not, and cannot, check the logic. This is your job. The compiler trusts you, perhaps foolishly.
// DANGER: Bad implementation that type-checks!
function getNumber(value: string): number;
function getNumber(value: number): number;
function getNumber(value: unknown): number {
// This satisfies the type checker... but is a terrible idea.
if (typeof value === 'string') {
return value.length; // Sure, a number. But is it the right one?
}
return value; // Also a number.
}
// This compiles but the logic is absurd. Overloads don't save you from yourself.
The implementation signature must be a superset of all the overloads. If your overloads return string or string[], your implementation can’t return boolean, even if you think it’s a good idea. It can’t. The type checker will rightly slap you.
A More Complex, Real-World Example
Let’s model a createElement function, similar to React’s. The return type changes based on the input string.
// The possible HTML element tag names and their corresponding types
type PossibleTags = {
div: HTMLDivElement;
span: HTMLSpanElement;
input: HTMLInputElement;
};
// Overloads for each tag we support
function createElement<T extends keyof PossibleTags>(tag: T): PossibleTags[T];
function createElement(tag: string): HTMLElement; // Fallback for unknown tags
// Implementation
function createElement(tag: string): HTMLElement {
// ... actual DOM creation logic would go here
return document.createElement(tag); // This returns an HTMLElement, which is compatible with our broader fallback.
}
// Beautiful, precise inference:
const divElement = createElement('div'); // type: HTMLDivElement
const inputElement = createElement('input'); // type: HTMLInputElement
const randomElement = createElement('made-up-tag'); // type: HTMLElement (falls back to the broader overload)
This is where overloads truly shine. We’ve created an incredibly type-safe API. The caller gets the exact type of element they asked for, and we’ve provided a sensible fallback for the edge cases. The implementation handles the messy reality of the DOM, while the overload signatures present a clean, intelligent interface to the world. That’s the goal.