Some functions return different things depending on what you pass in:
parse('2026-10-16') // one Date
parse(['2026-10-16', …]) // an array of Dates
With a single union signature, every caller gets the union back:
function parse(input: string | number | string[]): Date | Date[]
const d = parse('2026-10-16')
d.getFullYear() // error: Property 'getFullYear' does not exist on type 'Date[]'
You know it's a Date; the compiler doesn't. Overloads let you list
each pairing of argument and return type separately.
Writing overloads
List the signatures callers may use, then write one implementation whose signature is compatible with all of them:
function parse(input: string): Date
function parse(input: number): Date
function parse(input: string[]): Date[]
function parse(input: string | number | string[]): Date | Date[] {
if (Array.isArray(input)) return input.map((s) => new Date(s))
return new Date(input)
}
The first three lines are the overload signatures: they have no body, and they're what callers see. The last is the implementation signature, which callers can't use directly — it's there to type-check the body.
Overloads are several call signatures for one function, followed by one implementation. Callers only see the overload list; the implementation signature must be compatible with all of them but can't be called directly.
The rules
Order matters. TypeScript picks the first overload that fits, top to
bottom. Put specific signatures before general ones. If (input: unknown): unknown came first, it would match everything and the others would never
be used.
The implementation must cover every overload. Each overload's
parameters must be assignable to the implementation's, and the
implementation's return type must be compatible with each overload's. The
compiler checks this, but it can't check that the body actually returns a
Date[] for arrays — that's on you. Overloads are a promise you make to
callers.
Unions don't spread across overloads. As the last step above shows, a
string | number argument matches neither the string overload nor the
number one. If callers will pass unions, add an overload for the union
itself:
function parse(input: string | number): Date
Often you don't need overloads
When only the return type varies with an input type, a generic may do:
function first<T>(items: T[]): T | undefined {
return items[0]
}
When the mapping is a rule, a conditional type expresses it once:
type Parsed<T> = T extends string[] ? Date[] : Date
function parse<T extends string | number | string[]>(input: T): Parsed<T> {
return (Array.isArray(input) ? input.map((s) => new Date(s)) : new Date(input)) as Parsed<T>
}
This also handles unions: Parsed<string | string[]> distributes to
Date | Date[]. The cost is a cast in the body, because TypeScript can't
narrow a generic T inside the implementation. See
conditional types.
When options change behaviour, separate functions are clearer.
parseOne and parseMany are easier to read, document and search for than
one function with three personalities.
Overloads are the right tool when the signatures are genuinely different shapes — different numbers of arguments, or unrelated types — and the set is small and fixed.
Overloads you already use
The DOM's type definitions use overloads heavily:
document.createElement('canvas') // HTMLCanvasElement
document.createElement('input') // HTMLInputElement
That's an overload over a mapped type: createElement<K extends keyof HTMLElementTagNameMap>(tagName: K): HTMLElementTagNameMap[K], plus a fallback
returning HTMLElement for any other string. addEventListener works the
same way, so the event in a 'keydown' handler is typed as a
KeyboardEvent.
In classes and interfaces, overloads are written as several method signatures:
interface Cache {
get(key: string): string | undefined
get(key: string, fallback: string): string
}
Here the second overload promises that passing a fallback means you always get a string back.