The Grouparoo Blog


How TypeScript's any creates confusing function overload bugs

Tagged in Engineering 
By Tiger Oakes on 2020-10-13


What is any?

If you're working with TypeScript, chances are you'll work with the any type. any essentially turns off typechecking, and allows the corresponding variable to be used for anything. You can call any methods on an any variable, and they'll all return any as well. It's great when you can't write types for everything in your codebase.

let obj: any = { x: 0 };
// None of these lines of code are errors
const foo: any = obj.foo();
obj();
obj.bar = 100;

What are function overloads?

TypeScript has another neat feature called function overloads. Some JavaScript functions return different results based on the arguments you supply, and this can be represented in TypeScript by written multiple function types on top of each other. Only one function signature can match at a time. The matching overload is determined by the arguments you supply to the function. The first applicable overload will always be chosen.

function convertType(value: string): number;
function convertType(value: number): string;
function convertType(value: string | number): number | string {
  if (typeof value === "number") {
    return value.toString();
  } else {
    return parseFloat(value);
  }
}

const num: number = convertType("number");
const str: string = convertType(num);

Using overloading with arrays

Some functions want to transform an array in some way, and return an array with the same length and slightly modified types. A good example of this is Promise.all, which transforms an array of promises into a single promise that resolves with an array of values.

Using generic function definitions, you can infer the type of the array passed into Promise.all. However, the resulting type will be an array without positional data.

class Promise {
  static all<T>(array: (T | Promise<T>)[]): T[];
}

Promise.all([Promise.resolve(10), Promise.resolve("hello world")]).then(
  (result) => {
    // result's type is (number | string)[]
    // @ts-expect-error: Type 'string' is not assignable to type 'number'.
    const num: number = result[0];
  }
);

TypeScript does let you infer type of specific array items, but then you need to hardcode the length. Using overloading, you can have a couple of variations for different array lengths.

class Promise {
  static all<A, B, C>(
    array: [A | Promise<A>, B | Promise<B>, C | Promise<C>]
  ): [A, B, C];
  static all<A, B>(array: [A | Promise<A>, B | Promise<B>]): [A, B];
  static all<A>(array: [A | Promise<A>]): [A];
  // fallback to unknown length
  static all<T>(array: (T | Promise<T>)[]): T[];
}

Promise.all([Promise.resolve(10), Promise.resolve("hello world")]).then(
  (result) => {
    // result's type is [number, string]
    // This line is no longer an error
    const num: number = result[0];
  }
);

However, you can only write so many overloads. Eventually you need to fallback to an array with an unknown length like above. TypeScript's official type definitions for Promise.all hardcodes arrays up to length 10, and fallback after that.

How any creates problems with overloads

Remember that any will match with any type, and that function overloads use the first applicable overload. These two facts cause problems when you pass a variable with type any into a function with overloads.

function convertType(value: string): number;
function convertType(value: number): string;
function convertType(value: string | number): number | string {
  if (typeof value === "number") {
    return value.toString();
  } else {
    return parseFloat(value);
  }
}

const num: any = 10;
// @ts-expect-error: Type 'number' is not assignable to type 'string'.
const str: string = convertType(num);

The first overload is always used when you pass in a variable with type any, because it will be applicable to that signature. Even if there is a more generic signature later, the first overload is chosen. The overload ordering cannot be reversed, because the more generic signature will match every variable you pass in. As far as I know, you can't write a signature that only matches if the variable is type any, because matching with anything works in both directions.

In the case of Promise.all, the first function overload signature is an array with a hardcoded length of 10. That can create confusing bugs like this, where any is passed in and the resulting type becomes an array of exactly 10 unknowns.

Future solutions

Hardcoding the array length isn't great, because you can only support so many variations. TypeScript 4.0 introduces a new feature called "variadic tuple types", which lets you capture the exact array argument and transform it. Future type definitions for Promise.all may replace all the function overloads with a single function signature, removing the any bug entirely.

TypeScript may also add special handling for passing any into function overloads in the future. If you know of an existing issue, or see something I missed, let me know.


Stay up to date

We will let you know about our launch and new content.

Share this post