Skip to content

Overload call signature resolution resolves to first overload #59064

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
rotu opened this issue Jun 28, 2024 · 14 comments
Closed

Overload call signature resolution resolves to first overload #59064

rotu opened this issue Jun 28, 2024 · 14 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@rotu
Copy link

rotu commented Jun 28, 2024

πŸ”Ž 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

@MartinJohns
Copy link
Contributor

MartinJohns commented Jun 28, 2024

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.

@rotu
Copy link
Author

rotu commented Jun 28, 2024

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?

@MartinJohns
Copy link
Contributor

MartinJohns commented Jun 28, 2024

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.

@fatcerberus
Copy link

@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.

@rotu
Copy link
Author

rotu commented Jun 28, 2024

@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!).

@fatcerberus
Copy link

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.

@rotu
Copy link
Author

rotu commented Jun 29, 2024

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?

@fatcerberus
Copy link

fatcerberus commented Jun 29, 2024

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.

@rotu
Copy link
Author

rotu commented Jun 29, 2024

  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

@fatcerberus
Copy link

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.

@rotu
Copy link
Author

rotu commented Jun 29, 2024

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

@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Jul 9, 2024
@RyanCavanaugh
Copy link
Member

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'

@rotu
Copy link
Author

rotu commented Jul 9, 2024

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

@typescript-bot
Copy link
Collaborator

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 typescript-bot closed this as not planned Won't fix, can't repro, duplicate, stale Jul 12, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

5 participants