TypeScript Thursday - S01E04 - Function overloads

TypeScript Thursday - S01E04 - Function overloads

Welcome to TypeScript Thursday, a series of articles covering TypeScript's type system. Today's episode is about function overloads.

Featured on Hashnode

An overloaded function can be called in more than 1 way with different types of arguments, or with a different number of arguments. Each version of the function can also have a different return type.

You can find many overloaded methods in the DOM and Window APIs, such as scrollTo and postMessage:

window.scrollTo(0, 100);
window.scrollTo({
  left: 0,
  top: 100,
  behavior: 'smooth'
});
window.postMessage('foo', 'http://example.com');
window.postMessage('foo', {
  targetOrigin: 'http://example.com'
});

Both of the methods above accept different argument types and/or a different number of arguments, but they return void in both cases. Overloaded functions can also return different types depending on their arguments. The popular ReactQuery library uses an overloaded function to return a different type for the query data, depending on whether you pass an initial value or not:

const queryKey = ['data'];
const queryFn = (): string => 'foo';

const { data } = useQuery({ queryKey, queryFn });
//       ^? string | undefined
const { data } = useQuery({ queryKey, queryFn, initialData: 'foo' });
//       ^? string

We're going to use a similar technique to provide a different implementation for the <Dropdown> component from last episode TypeScript Thursday - S01E03 - Conditional types:

<Dropdown multi options={[1, 2, 3]} onChange={values => {}} />
//                                              ^? number[]

<Dropdown options={[1, 2, 3]} onChange={values => {}} />
//                                        ^? number

We're also going to see how we can create type safe test assertions:

// Will throw a runtime error, but we can
// make it raise a type error as well!
expect(42).toEqual('foo');

Let's dig in!

Declaring overloads

Overloading a function is only supported with function declarations and class constructors and methods. Arrow functions are not supported.

💡
microsoft/TypeScrip#47669 requests support for overloading arrow functions.

Each overload has its own function declaration that omits the function body. The overloads are then followed by a single function declaration and body:

function createDate(timestamp: number): Date;
function createDate(y: number, m: number, d: number): Date;
function createDate(/* ... */) {
  // Implementation will be covered in the next section.
}

The overloads and the implementation have to be together, with no other code separating them.

// Function implementation is missing or not
// immediately following the declaration.(2391)
function createDate(timestamp: number): Date;

const foo = 'bar';

function createDate(y: number, m: number, d: number): Date;

Overloaded class constructors and methods follow a similar pattern:

class Foo {
  constructor(x: number);
  constructor(x: string);
  constructor(/* ... */) { }

  bar(x: number): number;
  bar(x: string): string;
  bar(/* ... */) { /* ... */ }
}

We can extract the overloads to a type, but, unlike types for non-overloaded functions, they must always use the "method" syntax:

type CreateDate = {
  // "Method" syntax.
  (timestamp: number): Date;
  (y: number, m: number, d: number): Date;
}

type Bar = {
  // "Method" syntax.
  bar(data: number): number;
  bar(data: string): string;
}

// Not supported.
type ArrowSyntax = {
  // Duplicate identifier 'bar'.(2300)
  bar: (data: number) => number;
  bar: (data: string) => string;
}
💡
There are some important differences between the 2 types of syntax, which will influence how non-overloaded functions are type checked. This will be covered in a future episode.

Now that we've extracted a type for the overloaded function, we can annotate the class with it, but note that we must repeat the overloads inside the class:

type Bar = {
  bar(data: number): number;
  bar(data: string): string;
}

class Foo implements Bar {
  // Must be repeated here.
  bar(data: number): number;
  bar(data: string): string;
  bar(/* ... */) { /* ... */ }
}

Since function declarations cannot be annotated with a type, you have to first define them without an annotation and then assign them to a typed variable or property:

type Foo = {
  (data: number): number;
  (data: string): string;
}

type Bar = {
  bar(data: number): number;
  bar(data: string): string;
}

function overloadedFn(data: number): number;
function overloadedFn(data: string): string;
function overloadedFn(/* ... */): { /* ... */ }

const foo: Foo = overloadedFn;
const bar: Bar = {
  bar: overloadedFn
}

When calling an overloaded function TypeScript will choose an overload from top to bottom based on the types and/or the number of arguments passed in:

type StringAssertions = {
  toEqual: (expected: string) => boolean;
  toContain: (expected: string) => boolean;
}

type NumberAssertions = {
  toEqual: (expected: number) => boolean;
  toBeGreaterThan: (expected: number) => boolean;
}


function expect(actual: number): NumberAssertions;
function expect(actual: string): StringAssertions;
function expect(/* ... */) { /* ... */ }
// Matches the first overload.
expect(42).toEqual(42);
expect(42).toBeGreaterThan(0);

// Matches the second overload.
expect('foo').toEqual('bar');
expect('foobar').toContain('foo');

// Our test assertions are now type safe!
// Argument of type 'string' is not assignable
// to parameter of type 'number'.(2345)
expect(42).toEqual('foo');
// Argument of type 'number' is not assignable
// to parameter of type 'string'.(2345)
expect('foobar').toContain(42);

If none of the overloads match a type error will be raised and TypeScript will try to explain why none of the overloads match:

// No overload matches this call.
//  Overload 1 of 2, gave the following error.
//    Argument of type 'boolean' is not assignable to parameter of type 'string'.
//  Overload 2 of 2, gave the following error.
//    Argument of type 'boolean' is not assignable to parameter of type 'number'.(2769)
expect(true).toEqual(false);

Since the overloads are matched from top to bottom you should always put the more specific ones first.

type CommonAssertions<T> = {
  toEqual: (expected: T) => boolean;
}

function expect<T>(actual: T): CommonAssertions<T>;
function expect(actual: number): NumberAssertions;
function expect(actual: string): StringAssertions;

// Property 'toContain' does not exist on type
// 'CommonExpectations<string>'.(2339)
expect('foobar').toContain('foo');

In the example above, the first overload will always match, shadowing the rest of the overloads. You should always order the overloads from most specific to least specific:

// Specific overloads first.
function expect(actual: number): NumberAssertions;
function expect(actual: string): StringAssertions;
// General overloads last.
function expect<T>(actual: T): CommonAssertions<T>;

expect('foobar').toContain('foo');
expect(true).toEqual(true);

Implementing the body

An overloaded function has a single implementation body that will have to differentiate at runtime between the various overloads. The arguments of the overloaded implementation must be compatible with each overload.

Let's tackle the createDate example first, which can be called with a single timestamp, or with a year, month, date tuple:

// The overloads.
function createDate(timestamp: number): Date;
function createDate(y: number, m: number, d: number): Date;

// The single implementation.
function createDate(
  yOrTimestamp: number, m?: number, d?: number
): Date {
  if (m !== undefined && d !== undefined) {
    return new Date(yOrTimestamp, m, d);
  } else {
    return new Date(yOrTimestamp);
  }
}

Notice how we check if both the month and day arguments have been passed in. Our function has 2 overloads, 1 with a single argument, and the other one with 3. According to that, if the 2nd argument is passed in then surely the 3rd one is available as well, but because the function implementation has 2 optional arguments we must narrow down both of them.

💡
microsoft/TypeScript/#3442 explains why TypeScript doesn't support "proper" function overloading.

The function implementation is treated as standalone, with no connection to the overloads. It is the overloads which are checked against the implementation (see more in the next section). Because of this, things can get more complicated when the types of the arguments also change between overloads, like in the scrollTo example:

type Options = {
  left: number;
  top: number;
  behavior: 'smooth' | 'instant' | 'auto';
}

function scrollTo(x: number, y: number): void;
function scrollTo(options: Options): void;

function scrollTo(xOrOptions: number | Options, y?: number): void {
  if (typeof xOrOptions === 'number' && y !== undefined) {
    window.scrollTo(xOrOptions, y);
  } else if (typeof xOrOptions !== 'number') {
    window.scrollTo(xOrOptions);
  } else {
    throw new Error('You can only pass options, or x and y.');
  }
}

Similarly to the createDate example, our scrollTo functions can be called with a single Options argument, or with 2 number arguments. Therefore, we must check for the optional 2nd argument and for the type of the first argument. Moreover, we have to treat the case where the type from the 1st overload is mixed with the type from the 2nd overload. Since that is an invalid combination, we throw an error. We'd expect that we shouldn't have to deal with this invalid case, but we must since we're working in the constraints of a single function with a variable number of arguments with variable types.

Let's look at the expect example now, which can be called with a single argument, which can either be a number, a string, or anything else:

function expect(actual: number): NumberAssertions;
function expect(actual: string): StringAssertions;
function expect<T>(actual: string): CommonAssertions<T>;

function expect<T>(
  actual: T
): NumberAssertions | StringAssertions | CommonAssertions<T> {
  if (typeof actual === 'number') {
    return {
      toEqual: (expected: number) => actual === expected,
      toBeGreaterThan: (expected: number) => actual > expected,
    }
  }

  if (typeof actual === 'string') {
    return {
      toEqual: (expected: string) => actual === expected,
      toContain: (expected: string) => actual.includes(expected)
    }
  }

  return {
    toEqual: (expected: T) => actual === expected,
  }
}

We use typeof operator once more to distinguish between the overloads and return the proper assertions. If we return the incorrect types in any of the branches TypeScript will raise an error:

function expect(
  actual: string | number
): StringAssertions | NumberAssertions {
  if (typeof actual === 'string')
    // Type '{ ... }' is not assignable to type 'StringExpectations | NumberExpectations'.
    // Property 'toBeGreaterThan' is missing ... but required in type 'NumberExpectations'.(2322)
    return { toEqual: (expected: string) => actual === expected }

  if (typeof actual === 'number')
    // Type '{ ... }' is not assignable to type 'StringExpectations | NumberExpectations'.
    // Property 'toBeGreaterThan' is missing ... but required in type 'NumberExpectations'.(2322)
    return { toEqual: (expected: number) => actual === expected }

  throw new Error();
}

Notice how the error for both branches refers only to the missing toBeGreaterThan method, which belongs to NumberExpectations. Neither of them mentions the missing toContain method from StringExpectations.

💡
microsoft/TypeScript/#53614 mentions how the internal representation of unions depends on the order of the type declarations.

Lastly, let's tackle the ReactQuery and <Dropdown> React component examples. Both will be called with an object that will have a key with different values that change their behaviour. useQuery will change the return type based on initialData, while <Dropdown> will change the onChange callback based on multi.

function useQuery<T>(options: {
  queryKey: string[],
  queryFn: () => Promise<T>
}): { data: undefined | T };

function useQuery<T>(options: {
  queryKey: string[],
  queryFn: () => Promise<T>,
  initialData: T,
}): { data: T };

function useQuery<T>(options: {
  queryKey: string[],
  queryFn: () => Promise<T>,
  initialData?: T
}): { data: undefined | T } {
  let data = options.initialData;

  // Trigger and await queryFn...

  return { data };
}

Starting with useQuery, we define an overload for options without initialData that returns T | undefined, and one with initialData that returns T. The single implementation will have an optional initialData property and a return type of T | undefined to handle both branches. The implementation is pretty straightforward.

function Dropdown<Option>(props: {
  options: Option[];
  value: Option[];
  multi: true;
  onChange: (value: Option[]) => void
}): JSX.Element;

function Dropdown<Option>(props: {
  options: Option[];
  value: Option;
  multi?: false;
  onChange: (value: Option) => void
}): JSX.Element;

function Dropdown<Option>(props: {
  options: Option[];
  value: Option | Option[];
  multi?: boolean;
  onChange: (value: Option | Option[]) => void
}): JSX.Element {
  const onChange = (value: Option) => {
    if (props.multi) {
      props.onChange([
      //       ^? (value: Option | Option[]) => void
        ...props.value as Option[],
      //          ^? Option | Option[]
       value
      ]);
    } else {
      props.onChange(value);
      //       ^? (value: Option | Option[]) => void
    }
  }

  return <select onChange={e => onChange(e.target.value)}>
    ...
  </select>;
}

The <Dropdown> implementation looks very similar to that from S01E03 - Conditional types, but we had to use different type assertions. The onChange type in the implementation is a union of both overloads, so we can call it either with a single value or with a list of values, therefore we don't need a type assertion. However, the value type is also a union, so we must use a type assertion in the multi: true branch to tell TypeScript it can only be a list of values in that case. We've also had to define a return type for each overload, JSX.Element in this case since it's a React component.

Soundness

Let's talk about how TypeScript checks type compatibility between overloaded signatures and the implementation, inside the implementation, between different overloaded functions, and between an overloaded function and a "normal" function, and vice-versa.

For a non-overloaded function type any implementation must be assignable to that type, whereas for overloaded function each overload must be assignable to the implementation.

Let's start with the following non-overloaded function type:

type NormalFunction = (x: number | string) => number | string;

And now let's write some implementations for that type:

// OK.
const fn1: NormalFunction = (x: number | string | boolean): number => 42;

// Types of parameters 'x' and 'x' are incompatible.
// Type 'string | number' is not assignable to type 'number'.(2322)
const fn2: NormalFunction = (x: number): number => 42;

// Type 'number | boolean' is not assignable to type 'string | number'.(2322)
const fn3: NormalFunction = (x: number | string): number | boolean => 42;

The first implementation is compatible with the type, because

  1. the argument type number | string is assignable to the type number | string | booleanfrom the implementation.

  2. the return type numberfrom the implementation is assignable to the return type number | string.

The second implementation is not compatible because it breaks point number 1, while the third implementation breaks point number 2. Here's a visual summary:

type NormalFN = (x: ExpectedArgType) => ExpectedReturnType;
//                         |                  ^ 
//                         |                  |
//                         V                  |
let fn: NormalFn = (x: ActualArgType): ActualReturnType => { /*... */ }

Now let's look at an overloaded function:

function overloaded(x: number): number;
function overloaded(x: string): string;
function overloaded(x: number | string): number | string {
  /* ... */
}

Point 1 from before still applies to overloaded function. The argument types for each overload must be assignable to the argument types for the implementation.

Point 2 is reversed though. The return type of each overload, in our case number and string, must be assignable to the implementation return's type, number | string, and not the other way around.

function overloaded(x: ExpectedArgType1): ReturnType1;
//                           |                 |
function overloaded(x: ExpectedArgType2): ReturnType2;
//                           |                 |
//                           V                 V
function overloaded(x: ActualArgType): ActualReturnType;

Unfortunately, these rules can lead to unsound code:

type StringExpectations = {
  toEqual: (expected: string) => boolean;
  toContain: (expected: string) => boolean;
}

type NumberExpectations = {
  toEqual: (expected: number) => boolean;
  toBeGreaterThan: (expected: number) => boolean;
}

function expect(actual: string): StringExpectations;
function expect(actual: number): NumberExpectations;

// This is allowed, even though we're not returning the
// `toContain` or `toBeGreaterThan` methods.
function expect<T extends number | string>(actual: T): {
  toEqual: (expected: T) => boolean
} {
  return { toEqual: (expected: T) => true };
}

Point 1 still holds, because number and string are assignable to T extends number | string. Point 2 also holds because { toEqual, toContain } and { toEqual, toBeGreaterThan are both assignable to { toEqual } due to TypeScript's structural typing system (an object can be assigned to a type with less properties). However, the above produces a runtime error:

// TypeError: expect(...).toContain is not a function
expect('foobar').toContain('foo');

Here's an even more egregious example:

function expect(actual: string): StringExpectations;
function expect(actual: number): NumberExpectations;
function expect(actual: string | number) {
  return {};
}

We're not using an explicit return type so TypeScript infers it from the function body and in this case ends up with {}. Again, both StringExpectations and NumberExpectations are assignable to {} so the code passes the type checker, yet it will always produce a runtime error.

When we use an explicit return type, which should be a union of all the return types from the overloads, TypeScript will make sure the return type from the body is assignable to the union, but it won't check that we return all the members:

function overloaded(x: number): number;
function overloaded(x: string): string;
function overloaded(x: number | string): number | string {
  return 42;
}

// TypeError: overloaded(...).includes is not a function 
overloaded('foobar').includes('bar');

42 in the example above is assignable to number | string, but since we're never returning a string, calling the second overload results in a runtime error.

To summarise:

// Non-overloaded function.
type NormalFN = (x: ExpectedArgType) => ExpectedReturnType;
//                         |                  ^ 
//                         |                  |
//                         V                  |
let fn: NormalFn = (x: ActualArgType): ActualReturnType => { /*...*/ };



function overloaded(x: ExpectedArgType1): ReturnType1;
//                           |                 |
function overloaded(x: ExpectedArgType2): ReturnType2;
//                           |                 |
//                           V                 V
function overloaded(x: ActualArgType): ActualReturnType;

Assignability

Now let's look at how non-overloaded functions and overloaded functions are compared.

let dest = (data: number): boolean | number => { /*...*/ }

type Overloaded = {
  (data: string): boolean;
  (data: number): boolean;
}
let src: Overloaded = (data: number | string): boolean => { /* ... */ }

// OK.
dest = src;

The overloaded function above is assignable to the non-overloaded one because it has at least 1 overload that is compatible. The overloads are checked from top to bottom until a compatible one is found. If none can be found a type error will be raised:

let dest = (data: number): boolean | number => { /*...*/ }

type Overloaded = {
  (data: string): boolean;
  (data: number[]): boolean;
}
let src: Overloaded = /* ... */;

// Type 'Overloaded' is not assignable to type '(data: number) => boolean | number'.
// Types of parameters 'data' and 'data' are incompatible.
// Type 'number' is not assignable to type 'string'.(2322)
dest = src;

Unfortunately TypeScript will only describe why the first overload didn't match, as opposed to when you call an overloaded function and no overloads match, where TypeScript will try to describe all of them.

Docstrings

Function overloads allow us to document each overload separately. Without overloads, we can only have a single docstring that describes all the possible argument combinations.

type Options = {
  left: number;
  top: number;
  behavior: 'smooth' | 'instant' | 'auto';
}

/**
 * @param xOrOptions Used together with `y` to specify
 *   the left and top pixels offsets respectively.
 *   You can also pass an options object.
 * @param y The top pixel offset. Can only be used if
 *   `xOrOptions` is also a pixel offset.
 */
function scrollTo(xOrOptions: number | Options, y?: number): void { }

Trying to document the scrollTo example makes for a confusing docstring because we can either call it with 2 number arguments, or with a single Options argument.

Moreover, because the overloads differ in the meaning of their arguments, we had to name our first argument to represent that it can mean an x offset, or an options object. With overloads we can have 2 separate and clear docstrings:

/**
 * @param x The left pixel offset.
 * @param y The top pixel offset.
 */
function scrollTo(x: number, y: number): void { }

/**
 * @param options
 */
function scrollTo(options: Options): void { }

Hovering an overloaded call will show the docs for the selected overload:

Hover popup that shows the docs for overloaded call

Caveats

Let's take the following overloaded function:

function foo(x: number): void;
function foo(x: string): void;
function foo(x: number | string): void { }

It can be called with a number, or a string, so the following should just work, right?

function bar(x: number | string) {
  // No overload matches this call.
  // Overload 1 of 2, '(x: number): void', gave the following error.
  //  Argument of type 'string | number' is not assignable
  //  to parameter of type 'number'.
  // Overload 2 of 2, '(x: string): void', gave the following error.
  //  Argument of type 'string | number' is not assignable
  //  to parameter of type 'string'.
  // The call would have succeeded against this implementation, but
  // implementation signatures of overloads are not externally visible.
  foo(x);
}

Even though the function implementation can handle a number | string argument, TypeScript has to select 1 overload that is compatible, and neither of them can accept a union. The problem becomes clearer in the following example with multiple arguments:

function foo(a: number, b: number): void;
function foo(a: string, b: string): void;
function foo(a: number | string, b: number | string): void { }

function bar(a: number | string, b: number | string) {
  foo(a, b);
}

bar(42, 'baz');

The overloaded function can either be called with 2 numbers, or with 2 strings, but unlike bar, it cannot be called with 1 number and 1 string.

💡
See microsoft/TypeScript#17471 and microsoft/TypeScript#14107 for discussions on improving these cases.

Another caveat is how conditional types work on overloaded functions.

function foo(x: number): void;
function foo(x: string): void;
function foo(x: number | string): void { }

type Params = Parameters<typeof foo>;
//     ^? [x: string]

The builtin conditional type Parameters (covered in S01E03 - Conditional Types) only returns the parameters type of the last overload.

💡
microsoft/TypeScript/#32164 proposes that Parameters should return a union for overloaded function.

Alternatives

Function overloads are 1 option for creating functions that can be called in more than 1 way and can potentially return more than 1 type. Let's look at a few examples and see how we can implement them without using overloads.

function addEventListener(
  type: 'beforeunload',
  listener: (event: BeforeUnloadEvent) => void
);
function addEventListener(
  type: 'onkeydown', 
  listener: (event: KeyboardEvent) => void
);

The overloads above simply change the type of the listener callback based on the type argument. We can represent this mapping with a record type and use an indexed access type (covered in S01E02 - Mapped types) to retrieve the listener type:

type ListenerMap = {
  beforeunload: BeforeUnloadEvent;
  onkeydown: KeyboardEvent
}

function addEventListener<T extends keyof ListenerMap>(
  type: T,
  listener: (event: ListenerMap[T]) => void
) { }

This technique doesn't work when we don't have discrete values to map, such as in the expect example:

function expect(actual: number): NumberAssertions;
function expect(actual: string): StringAssertions;
function expect<T>(actual: T): CommonAssertions<T>;

We can at most create a mapping for number and string using index signatures (again, covered in S01E02 - Mapped types), but we can use conditional types instead (covered in S01E03 - Conditional Types):

function expectWithConditionals<T>(
  actual: T
) : T extends string ? StringAssertions
  : T extends number ? NumberAssertions
  : CommonAssertions<T>
{
  if (typeof actual === 'string') {
    // Type '{ ... }' is not assignable to type
    // 'T extends string ? StringAssertions : ....(2322)
    return { /* ... */ };
  } else {
    // ...
  }
}

As we've seen in S01E03 - Conditional Types, we can't usually assign anything to a conditional type, so we must use type assertions or suppress the errors. Conditional types can also become messy when you have to deal with multiple arguments.

Sometimes it can be clearer if you simply split the function up:

// With overloads.
function getOrSet(): string;
function getOrSet(value: string): void;
function getOrSet(value?: string): string | undefined {
  /* ... */
}

// Separate functons.
function get(): string { /* ... */ }
function set(value: string): void { /* ... */ }

The makeDate example has overloads that differ in the number of arguments, but they all have the same types.

function makeDate(timestamp: number): Date;
function makeDate(y: number, m: number, d: number): Date;

We can use a union of tuples and use the length to choose a branch:

function makeDate(
  ...args: [number] | [number, number, number]
): Date {
  if (args.length === 1)
    return new Date(args[0]);
  else
    return new Date(args[0], args[1], args[2]);
}
💡
Future episodes will cover both unions and tuples, so stay tuned!

Summary

Thank you for reading and I hope you enjoyed the article. Below you can find the key takeaways and a shareable sketch note. Make sure to catch the next episode about union types, which can be used as an alternative to function overloads in some cases!

🤎
If you liked this article, maybe you'd also like some of my open-source projects at github.com/NiGhTTraX.

Takeaways:

  • Overloaded functions can have multiple signatures with different arguments and return types.

  • Overloads are supported for function declarations, class methods and constructors, but not for arrow functions.

  • Overloaded functions have a single body handling all possible inputs, so you must use type guards or other checks at runtime to "select" an overloaded branch.

  • The implementation is type checked as a standalone function, without any connection to the overloads. This can force handling branches that should never be reached at runtime, in order to satisfy the exhaustiveness checks.

  • Each overload's arguments and return type are checked for assignability against the body's arguments and return type respectively, unlike non-overloaded functions. This can lead to unsound code.

  • Each overload can have its own docstring, which can improve developer experience.

  • Overloaded functions have some caveats, both when implementing them and when calling them. Consider alternatives like conditional types S01E03, mapped types S01E02, unions, or simply splitting the function.

Sketchnotes