TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Overload call signature resolution resolves to first overload

Open rotu opened this issue 1 year ago • 11 comments

🔎 Search Terms

overload, multiple call signature

🕗 Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about overloads

⏯ Playground Link

https://www.typescriptlang.org/play/?ts=5.5.2&ssl=15&ssc=1&pln=1&pc=1#code/JYOwLgpgTgZghgYwgAgHIHswG8CwAoZQ5ACgA8AueAGwGcIBKcsKAVxQHp3kbgBzEOGBZQUARnxESFZm0bU6yTtz4ChI5ACZ8EomXIAjdOioQ4IRoeOmQirj36DhKAMwA6ZAFUQCE3CjIAdwALCDAQ-zDgGm4g9BYqABNkfRQEiB8-CCT0fxBMfABffDSM9QA3P2Q8sHIMMHwEdBAaMCrMAEEQAE9kAF42sGIZFDhosy76W2RhgG5kllaaWPiklOQAA0tfEHX8JSIAPQB+bTwwLoAHFAANPuQAJVDhEAAVS4gAHjqAPimt6zm8hQwBgygcahcyCiyHQAFtgGBIAk9lxCMcGk0WgMAEJGbZ3apDVgjaL-MyTJRkkBzaBQHJQ0H2VROZDOKHROEIpEoyTovBAA

💻 Code

interface Not{
    (x:false):true // signature 1
    (x:true):false // signature 2

    (x:boolean):boolean // signature 3. Unclear whether this should be declared or not
}
declare var not:Not
const notAny = not(true as any) // true; but should be `boolean`
//    ^?

type X = ReturnType<Not> // boolean; false if signature 3 is omitted
//   ^?
const notBoolean = not(true as boolean) // boolean; error if signature 3 is omitted
//    ^?

🙁 Actual behavior

x types as true, the first signature of the overload.

🙂 Expected behavior

x should be boolean, the return type of signature 3.

Additional information about the issue

Note that #14107 would obviate the need for signature 3.

Originally discovered based on PR Feedback.

It is unclear to this author whether the implementation signature belongs in a pure declaration.

The FAQ indicates the implementation signature will typically not be externally visible:

When having at least one overload signature declaration, only the overloads are visible. The last signature declaration, also known as the implementation signature, does not contribute to the shape of your signature.

Previous discussion suggests the "catch-all" should actually be visible for consumers:

The intended thing for users to do is ... Have a "catch-all" overload, last, that represents the behavior of the function when overload resolution is ambiguous, whose return type should be the union of all other signatures' return types

rotu avatar Jun 28 '24 17:06 rotu

You forgot to fill out the issue template.


type X = ReturnType<Not> // boolean; false if signature 3 is omitted

This is working as intended and documented: https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#inferring-within-conditional-types

When inferring from a type with multiple call signatures (such as the type of an overloaded function), inferences are made from the last signature (which, presumably, is the most permissive catch-all case).


It is unclear to this author whether the implementation signature belongs in a pure declaration.

Implementation signatures can't be part of interfaces because interfaces don't have implementations. Whether you should have a permissive "catch all" signature as part of your interface or not really depends on your use case.

The only weird thing here is that true as any resolves to the true overload, but I feel I've seen this in another issue already.

MartinJohns avatar Jun 28 '24 17:06 MartinJohns

Implementation signatures can't be part of interfaces because interfaces don't have implementations. Whether you should have a permissive "catch all" signature as part of your interface or not really depends on your use case.

Yes, I'm confusing the terms "implementation signature" and "catch all signature". It seems like catch-all signatures are a workaround inspired by implementation signatures. And they would largely not be needed if function overload distributed over union.

The only weird thing here is that true as any resolves to the true overload, but I feel I've seen this in another issue already.

That's my understanding too. Maybe a duplicate of #39833?

rotu avatar Jun 28 '24 18:06 rotu

Yes, I'm confusing the terms "implementation signature" and "catch all signature".

function foo(v: 1): void;
function foo(v: 2): void;
function foo(v: 1 | 2): void; // catch all overload, just another overload signature that's visible to the caller
function foo(v: 1 | 2): void { // implementation signature, not visible for caller
    // implementation be here
}

And they would largely not be needed if function overload distributed over union.

No idea what you mean. This behavior is intentional.

That's my understanding too. Maybe a duplicate of #39833?

Yes, that's the one.

MartinJohns avatar Jun 28 '24 18:06 MartinJohns

@MartinJohns By distribution over unions I think he means that given the overloads...

declare function foo(v: 1): void;
declare function foo(v: 2): void;

...it should be legal to call foo(v) with a v typed as 1 | 2. Which comes up semi-regularly and I'm almost positive there's at least one open issue about it. My understanding was that the reason that isn't done is for performance reasons because it becomes combinatorially explosive in the number of parameters and union members.

fatcerberus avatar Jun 28 '24 19:06 fatcerberus

@MartinJohns By distribution over unions I think he means that given the overloads...

declare function foo(v: 1): void;
declare function foo(v: 2): void;

...it should be legal to call foo(v) with a v typed as 1 | 2. Which comes up semi-regularly and I'm almost positive there's at least one open issue about it. My understanding was that the reason that isn't done is for performance reasons because it becomes combinatorially explosive in the number of parameters and union members.

Exactly. In this your example, it simplifies out to void. In general, the return types of chained calls will tend to unknown or any. Only if the output types are disjoint will you get the combinatorial growth (and it’s a sum not the product behavior of generic or template string types!).

rotu avatar Jun 28 '24 23:06 rotu

The combinatorial behavior I’m talking isn’t about the return types, but how much work has to be done to typecheck the call sites. Basically if there are multiple arguments, each with a union type, you’d have to take each union apart and, for each member, check if it matches one of the overloads. That’s on average exponential in the number of arguments provided.

fatcerberus avatar Jun 29 '24 00:06 fatcerberus

The combinatorial behavior I’m talking isn’t about the return types, but how much work has to be done to typecheck the call sites. Basically if there are multiple arguments, each with a union type, you’d have to take each union apart and, for each member, check if it matches one of the overloads. That’s on average exponential in the number of arguments provided.

I’m still not seeing it. Could you give a concrete example?

rotu avatar Jun 29 '24 00:06 rotu

Suppose you have

declare function foo(x: A, y: A): void;
declare function foo(x: A, y: B): void;
declare function foo(x: B, y: A): void;
declare function foo(x: B, y: B): void;

declare let x: A | B;
declare let y: A | B;

foo(x, y);

This call should succeed according to the types. But to know that, you have to answer all of these in the affirmative:

  • Is foo(A, A) a valid call?
  • Is foo(A, B) a valid call?
  • Is foo(B, A) a valid call?
  • Is foo(B, B) a valid call?

and each of those individual checks has to check all the overloads for a match. Now increase the size of the unions and/or the number of parameters and this very quickly gets out of hand.

fatcerberus avatar Jun 29 '24 02:06 fatcerberus

  1. That's not a combinatorial explosion in checking difficulty - it's one case per overload signature. Yes, it would require you to declare combinatorially many cases, but if the function can be factored into a single signature, it makes sense to do so (foo(x:A|B, y:A|B): void). If not, then the most general signature is awkward anyway.
  2. TypeScript already allows assigning a tuple of union types to a union of tuple types and vice versa. That's morally the same process:
    declare let x: [A,A] | [A,B] | [B,A] | [B,B]
    declare let y: [A|B, A|B]
    x = y
    

rotu avatar Jun 29 '24 03:06 rotu

it's one case per overload signature

It's not though - if all the overloads aren't there, it might be able to short-circuit once it finds a failing case, but in principle it still has to enumerate all the possible combinations of union members. The "40 questions" bit I described would still happen, even if the matching overloads aren't there.

TypeScript already allows assigning a tuple of union types to a union of tuple types and vice versa.

Indeed, but IIRC there's already a (fairly low) limit to the size of the unions that can be expanded out during checking that way before it just fails. Something like 25 if my memory is right.

fatcerberus avatar Jun 29 '24 04:06 fatcerberus

it's one case per overload signature

It's not though - if all the overloads aren't there, it might be able to short-circuit once it finds a failing case, but in principle it still has to enumerate all the possible combinations of union members. The "40 questions" bit I described would still happen, even if the matching overloads aren't there.

Okay I think I understand. You're assuming the implementation will be "normalize the types of both the arguments and the parameters to unions of tuple types and check that every argument case is covered by a parameter case". This is a really natural approach to take - canonicalizing to a sum (union) of products (Cartesian product).

If so, then yes, it is an expensive check! But that assumes the worst-case scenario. You don't need to eagerly expand out the argument nor the parameter types into unions. Keep the argument type as a tuple and only split into cases when an overload partially matches the argument type.

e.g. for arguments [A, B] and an overload with parameters [D, E], the case [A&D, B&E] is handled by the overload. You then need to check [Exclude<A,D>, B] | [A, Exclude<B,E>] against the other overloads. If A is disjoint from D, it simplifies to [A,B]. And if A is a subset of D, it simplifies to [A, Exclude<B,E>].

You could also try to gather the argument type into a product of sums (e.g. foo) but I expect that's not very useful in practice - if the function can be declared that way, it probably is.

TypeScript already allows assigning a tuple of union types to a union of tuple types and vice versa.

Indeed, but IIRC there's already a (fairly low) limit to the size of the unions that can be expanded out during checking that way before it just fails. Something like 25 if my memory is right.

Seems like you're right! And it doesn't even give a meaningful error message! This does strongly suggest that a casewise analysis like the above is being used in the code. And that it's an opportunity for optimization.

EDIT: the code - your memory is good!

Playground Link

rotu avatar Jun 29 '24 06:06 rotu

Overload resolution selects the first matching signature (everyone loves it when language use simple algorithms, right? right??). There have been requests to do something more involved when any is involved, but none of them have really succeeded in staking out a definition doesn't break huge swathes of user code; many times overloads are arranged with a "common" case first and an "uncommon" case second and people want the "common" one to be selected in an any invocation.

The one caveat to "first" is that there's an initial pass that tries to discard assignments to any, so if you really need your interface to do a particular thing on any, you can declare that without breaking more-specific inferences:

interface Not{
    (x: any): boolean;

    (x:false):true // signature 1
    (x:true):false // signature 2

    (x:boolean):boolean // signature 3. Unclear whether this should be declared or not
}
declare var not:Not
const p = not(true);
//    ^? still 'false'

RyanCavanaugh avatar Jul 09 '24 19:07 RyanCavanaugh

The one caveat to "first" is that there's an initial pass that tries to discard assignments to any, so if you really need your interface to do a particular thing on any, you can declare that without breaking more-specific inferences

Clever workaround, but that defeats the type annotation on the argument. Somehow the only workaround that seems to work is to make it a generic function:

interface Not2 {
   <const T extends boolean> (x:T) : T extends true ? false : T extends false ? true : never
}

Overload resolution selects the first matching signature.

Somehow though, adding (or moving) the catch-all signature first results in matching the second overload, with weird results:

interface Not{
    (x: boolean): boolean;
    (x: false): true;
    (x: true): false;
    (x: boolean): boolean;
}

declare var not:Not
const notAny = not(true as any) // true !?!?
//    ^?

Playground Link

It seems in intellisense that the boolean-accepting overloads are being shuffled to number 3 and 4 in the list!

Screenshot 2024-07-09 154002

rotu avatar Jul 09 '24 20:07 rotu

This issue has been marked as "Working as Intended" and has seen no recent activity. It has been automatically closed for house-keeping purposes.

typescript-bot avatar Jul 12 '24 01:07 typescript-bot