Overload signatures, union types and "No overload matches this call" error

Viewed 661

Imagine a TypeScript (4.2.2) function that receives a string or a Promise<string> and returns an answer using the same type it has originally received, for example:

function trim(textOrPromise) {
    if (textOrPromise.then) {
        return textOrPromise.then(value => result.trim());
    }
    return textOrPromise.trim();
}

I'd like to define the signature of such function using generics to indicate that passing in a Promise always results in a Promise, and passing in a string results in a string

To do that, I define the following overloads:

function trim(text: Promise<string>): Promise<string>
function trim(text: string): string
function trim(text: any): any {
    if (text.then) {
        return text.then(result => result.trim());  // returns Promise<string>
    }
    return text.trim();                             // returns string
}

This transpiles correctly as long as the parameter is defined explicitly as string or a Promise<string>:

let value: string
trim(value)                          // fine
let value: Promise<string>
trim(value)                          // fine

However, when I define the parameter using a union type (Promise<string> | string):

let value: Promise<string> | string
trim(value)                          // error: TS2769

I get the following transpilation error:

TS2769: No overload matches this call.
   Overload 1 of 2, '(text: Promise<string>): Promise<string>', gave the following error.
    Argument of type 'string | Promise<string>' is not assignable to parameter of type 'Promise<string>'.
       Type 'string' is not assignable to type 'Promise<string>'.
   Overload 2 of 2, '(text: string): string', gave the following error.
     Argument of type 'string | Promise<string>' is not assignable to parameter of type 'string'.
       Type 'Promise<string>' is not assignable to type 'string'.

Interestingly enough, when I add the union type to the function signature, the example transpiles and behaves correctly

function trim(text: Promise<string>): Promise<string>
function trim(text: string): string
function trim(text: Promise<string> | string): Promise<string> | string
function trim(text: any): any {
    if (text.then) {
        return text.then(result => result.trim());
    }
    return text.trim();
}

let value: Promise<string> | string
trim(value)                          // fine

With this last implementation, TypeScript knows that when a function receives a Promise it will return a Promise, and when it receives a string it returns a string. As opposed to a union of Promise<string> | string as the third overload would suggest.

I'd be grateful if someone could explain this behaviour and the reason behind having to add an overload for union types.

2 Answers

Passing Unions to An Overload

Typescript is not able to "split up" the union before checking it against your overload signatures. It is checking your variable of type Promise<string> | string against each overload individually. The union is not assignable to either of its members so there is no overload signature that accepts Promise<string> | string.

This is a known behavior and some of the GitHub issues about this date back years.

The argument against changing the typescript behavior seems to be that the number of possible combinations can explode quickly when you have a function with multiple arguments that each accept multiple types.

Since typescript doesn't support this on its own, you have to manually add an overload that accepts the union and returns the union, like you have already done. Note that the implementation signature (the last line of the overload) does not count as one of the overloads. So just having the union in the implementation signature is not enough.

function trim(text: Promise<string>): Promise<string>;
function trim(text: string): string;
function trim(text: Promise<string> | string): Promise<string> | string;
function trim(text: Promise<string> | string): Promise<string> | string {
  if (typeof text === "string") {
    return text.trim();
  } else {
    return text.then(result => result.trim());
  }
}

Generics

We don't have this problem when using generics instead of overloads because extends includes the union. T extends A | B means that T can be A, B, or the union A | B (or any more refined version of these, which I'll get into in a bit).

function trim<T extends Promise<string> | string>(text: T): T {

However generics raise their own issues when it comes to the implementation of the function because refining the type of the variable text doesn't refine the type of the generic T. That behavior makes sense in general, given that the type T could be a union.

It's frustrating here especially because we are returning the same type as the input. So if we know that text is a string then we know that T must include string so it should be fine to return a string, right? Nope.

It feels like we only have three possibilities (string, Promise<string>, Promise<string> | string). But extends opens infinite possible types for T that are refinements of those. If T is the literal string " A " then the string "A" that we return from text.trim() really isn't assignable to T. So we've solved one issue but created another. We have to make as assertions to avoid errors like:

Type 'string' is not assignable to type 'T'. 'string' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'string | Promise'.(2322)

And there are some weird edge cases where those assertions might be incorrect.

function trim<T extends Promise<string> | string>(text: T): T {
  if (text instanceof Promise) {
    return text.then((result) => result.trim()) as T;
  }
  return (text as string).trim() as T;
}

const a = trim(' ' as Promise<string> | string);  // type: string | Promise<string>
const b = trim(' ');                              // type: ' ' -- actually wrong!
const c = trim(' ' as string);                    // type: string
const d = trim(new Promise<string>(() => ' '));   // type: Promise<string>
const e = trim(new Promise<' '>(() => ' '));      // type: Promise<' '> -- wrong again!

Typescript Playground Link

So I would prefer the overloaded version even though you need that extra line.

Have you tried Conditional types?

in your case, it will look something like this

function trim<T extends string | Promise<string>>(
 text: T
): T extends Promise<string> ? Promise<string> : string {
  ....
}

Also, you asked about why in order for it to type check it's required to do overload signatures

function trim(text: Promise<string>): Promise<string>
function trim(text: string): string

This is essentially a way of telling TS that if string is passed return type should be string and if Promise is passed return type should be Promise. Because TS doesn't have any runtime type checking mechanics it cannot determine at runtime a type of value you've passed to function.

Also, you don't need a final overloading statement with `any.

function trim(text: any): any {


function trim(text: Promise<string> | string): Promise<string> | string {

would be enough since it accommodates every case you declared using overload signatures above.

your final code would look like this

function trim(text: Promise<string>): Promise<string>;
function trim(text: string): string;
function trim(text: Promise<string> | string): Promise<string> | string;
function trim(text: Promise<string> | string): Promise<string> | string {
  if (text instanceof Promise) {
   return text.then((result) => result.trim());
  }
  return text.trim();
}

I hope I've answered your question.

Related