HomeArticles

TypeScript: The humble function overload

Stefan Baumgartner

Stefan on Mastodon

More on TypeScript

With the most recent type system features like conditional types or variadic tuple types, one technique to describe a function’s interface has faded into the background: Function overloads. And there’s a good reason for that. Both features have been implemented to deal with the shortcomings of regular function overloads.

See this concatenation example directly from the TypeScript 4.0 release notes. This is an array concat function:

function concat(arr1, arr2) {
return [...arr1, ...arr2];
}

To correctly type a function like this so it takes all possible edge cases into account, we would end up in a sea of overloads:

// 7 overloads for an empty second array
function concat(arr1: [], arr2: []): [];
function concat<A>(arr1: [A], arr2: []): [A];
function concat<A, B>(arr1: [A, B], arr2: []): [A, B];
function concat<A, B, C>(arr1: [A, B, C], arr2: []): [A, B, C];
function concat<A, B, C, D>(arr1: [A, B, C, D], arr2: []): [A, B, C, D];
function concat<A, B, C, D, E>(arr1: [A, B, C, D, E], arr2: []): [A, B, C, D, E];
function concat<A, B, C, D, E, F>(arr1: [A, B, C, D, E, F], arr2: []): [A, B, C, D, E, F];)
// 7 more for arr2 having one element
function concat<A2>(arr1: [], arr2: [A2]): [A2];
function concat<A1, A2>(arr1: [A1], arr2: [A2]): [A1, A2];
function concat<A1, B1, A2>(arr1: [A1, B1], arr2: [A2]): [A1, B1, A2];
function concat<A1, B1, C1, A2>(arr1: [A1, B1, C1], arr2: [A2]): [A1, B1, C1, A2];
function concat<A1, B1, C1, D1, A2>(arr1: [A1, B1, C1, D1], arr2: [A2]): [A1, B1, C1, D1, A2];
function concat<A1, B1, C1, D1, E1, A2>(arr1: [A1, B1, C1, D1, E1], arr2: [A2]): [A1, B1, C1, D1, E1, A2];
function concat<A1, B1, C1, D1, E1, F1, A2>(arr1: [A1, B1, C1, D1, E1, F1], arr2: [A2]): [A1, B1, C1, D1, E1, F1, A2];
// and so on, and so forth

And this only takes into account arrays that have up to six elements. Variadic tuple types help greatly with situations like this:

type Arr = readonly any[];

function concat<T extends Arr, U extends Arr>(arr1: T, arr2: U): [...T, ...U] {
return [...arr1, ...arr2];
}

You can easily see how boils down the function signature to its point while being flexible enough for all possible arrays to come. The return value also maps to the return type. No extra assertions, TypeScript can make sure that you are returning the correct value.

It’s a similar situation with conditional types. This example comes directly from my book. Think of software that retrieves orders based on customer, article, or order ID. You might want to create something like this:

function fetchOrder(customer: Customer): Order[]
function fetchOrder(product: Product): Order[]
function fetchOrder(orderId: number): Order
// the implementation
function fetchOrder(param: any): Order | Order[] {
//...
}

But this is just half the truth. What if you end up with ambiguous types where you don’t know exactly if you get only a Customer, or only a Product. You need to take care of all possible combinations:

function fetchOrder(customer: Customer): Order[]
function fetchOrder(product: Product): Order[]
function fetchOrder(orderId: number): Order
function fetchOrder(orderId: Customer | Product): Order[]
function fetchOrder(orderId: Customer | number): Order | Order[]
function fetchOrder(orderId: number | Product): Order | Order[]
// the implementation
function fetchOrder(param: any): Order | Order[] {
//...
}

Add more possibilities, you end up with more combinations. Here, conditional types can reduce your function signature tremendously.

type FetchParams = number | Customer | Product;

type FetchReturn<T> = T extends Customer ? Order[] :
T extends Product ? Order[] :
T extends number ? Order: never

function fetchOrder<T extends FetchParams>(params: T): FetchReturn<T> {
//...
}

Since conditional types distribute a union, FetchReturn returns a union of return types.

So, there is good reason to use those techniques instead of drowning in too many function overloads. This bears the question: Do we still need function overloads?

TL;DR: Yes, we need function overloads.

Here are a few examples.

Different function shapes #

One scenario where function overloads still are very handy is if you have different argument lists for your function variants. This means that not only the arguments (parameters) themselves can have some variety (this is where conditionals and variadic tuples are fantastic), but also the number and position of arguments.

Imagine a search function that has two different ways of being called:

  1. Call it with the search query. It returns a Promise you can await.
  2. Call it with the search query and a callback. In this scenario, the function does not return anything.

This can be done with conditional types, but is very unwieldy:


// => (1)
type SearchArguments =
// Argument list one: a query and a callback
[query: string, callback: (results: unknown[]) => void] |
// Argument list two:: just a query
[query: string];

// A conditional type picking either void or a Promise depending
// on the input => (2)
type ReturnSearch<T> = T extends [query: string] ? Promise<Array<unknown>> : void;

// the actual function => (3)
declare function search<T extends SearchArguments>(...args: T): ReturnSearch<T>;

// z is void
const z = search("omikron", (res) => {

})

// y is Promise<unknown>
const y = search("omikron")

Here’s what we did:

  1. We defined our argument list using tuple types. Since TypeScript 4.0, we can name tuple fields just like we would do it with objects. We create a union because we have two different variants of our function signature
  2. The ReturnSearch type selects the return type based on the argument list variant. If it’s just a string, return a Promise, if it has a callback, return void.
  3. We add our types by constraining a generic variable to SearchArguments, so that we can correctly select the return type

That is a lot! And it features a ton of complex features that we love to see in TypeScript’s feature list: Conditional types, generics, generic constraints, tuple types, union types! We get some nice auto-complete, but it’s nowhere the clarity of a simple function overload:


function search(query: string): Promise<unknown[]>
function search(query: string, callback: (result: unknown[]) => void): void
// This is the implementation, it only concerns you
function search(query: string, callback?: (result: unknown[]) => void): void | Promise<unknown> {
// Implmeent
}

We only use a union type for the implementation part. The rest is very explicit and clear. We know our arguments, we know what to expect in return. No ceremony, just simple types. The best part of function overloads is that the actual implementation does not pollute the type space. You can go for a round of anys and just don’t care.

Exact arguments #

Another situation where function overloads can make a lot of things easier is when you are in need of exact arguments and their mapping. Let’s look at a function that applies an event to an event handler. E.g. we have a MouseEvent and want to call a MouseEventHandler with it. Same for keyboard events, etc. If we use conditionals and union types to map event and handler, we might end up with something like this:

// All the possible event handlers
type Handler =
MouseEventHandler<HTMLButtonElement> |
KeyboardEventHandler<HTMLButtonElement>;

// Map Handler to Event
type Ev<T> =
T extends MouseEventHandler<infer R> ? MouseEvent<R> :
T extends KeyboardEventHandler<infer R> ? KeyboardEvent<R> : never;

// Create a
function apply<T extends Handler>(handler: T, ev: Ev<T>): void {
handler(ev as any); // We need the assertion here
}

At a first glance, this looks fine. It might be a bit cumbersome though if you think about all the variants that you need to keep track of.

There’s a bigger problem, though. The way TypeScript deals with all possible variants of event is causing an unexpected intersection. This means that in the function body, TypeScript can’t tell what kind of handler you are passing. Therefore it also can’t tell which kind of event we’re getting. So TypeScript says that the event can be both. A mouse event, and a keyboard event. You need to pass handlers that can deal with both. Which is not how we intend our function to work.

The actual error message is TS 2345: Argument of type ‘KeyboardEvent | MouseEvent<HTMLButtonElement, MouseEvent>’ is not assignable to parameter of type ‘MouseEvent<HTMLButtonElement, MouseEvent> & KeyboardEvent’.

This is why we need an as any type assertion. Just to make it possible to actually call the handler with the event.

So, the function signature works in a lot of scenarios:

declare const mouseHandler: MouseEventHandler<HTMLButtonElement>;
declare const mouseEv: MouseEvent<HTMLButtonElement>
declare const keyboardHandler: KeyboardEventHandler<HTMLButtonElement>;
declare const keyboardEv: KeyboardEvent<HTMLButtonElement>;

apply(mouseHandler, mouseEv); // yeah!
apply(keyboardHandler, keyboardEv) // cool!
apply(mouseHandler, keyboardEv) // 💥breaks like it should!

But once there’s ambiguity, stuff doesn’t work out as it should:

declare const mouseOrKeyboardHandler:
MouseEventHandler<HTMLButtonElement> |
KeyboardEventHandler<HTMLButtonElement>;;

// No wait, this can cause problems!
apply(mouseOrKeyboardHandler, mouseEv);

When mouseOrKeyboardHandler is a keyboard handler, we can’t reasonably pass a mouse event. Wait a second. This is exactly what the TS2345 error from above tried to tell us! We just shifted the problem to another place and made it silent with an as any assertion. Oh no!

Explicit, exact function signatures make everything easier. The mapping becomes clearer, the type signatures easier to understand, and there’s no need for conditionals or unions.

// Overload 1: MouseEventHandler and MouseEvent
function apply(
handler: MouseEventHandler<HTMLButtonElement>,
ev: MouseEvent<HTMLButtonElement>): void
// Overload 2: KeyboardEventHandler and KeyboardEvent
function apply(
handler: KeyboardEventHandler<HTMLButtonElement>,
ev: KeyboardEvent<HTMLButtonElement>): void
// The implementation. Fall back to any. This is not a type!
// TypeScript won't check for this line nor
// will it show in the autocomplete.
//This is just for you to implement your stuff.
function apply(handler: any, ev: any): void {
handler(ev);
}

Function overloads help us with all possible scenarios. We basically make sure that there no ambiguous types:

apply(mouseHandler, mouseEv); // yeah!
apply(keyboardHandler, keyboardEv) // cool!
apply(mouseHandler, keyboardEv) // 💥 breaks like it should!
apply(mouseOrKeyboardHandler, mouseEv); // 💥 breaks like it should

For the implementation, we can even use any. This is not a type seen by TypeScript, this is just for you to implement your stuff. Since you can make sure that you won’t run into a situation that implies ambiguity, we can rely on the happy-go-lucky type and don’t need to bother.

Bottom line #

Function overloads are still very useful and for a lot of scenarios the way to go. They’re easier to read, easier to write, and in a lot of cases more exact than what we get with other means.

But it’s not either-or. You can happily mix and match conditionals and function overloads if your scenario needs it. As always, here are some playgrounds:

More articles on TypeScript

Stay up to date!

3-4 updates per month, no tracking, spam-free, hand-crafted. Our newsletter gives you links, updates on fettblog.eu, conference talks, coding soundtracks, and much more.