Open craigphicks opened 9 months ago
Sounds like a duplicate of #57089. Why open another issue? You can just edit and open your other one. π€·ββοΈ
const rab = ab0.foo(); // actual string, expecting string | number
That still doesn't make sense to me. You have one function that always returns string
, and another that always returns a number
. Then you say you have an intersection of them, that means the return type must also be an intersection, not a union. So instead of string | number
it should be string & number
.
I don't see the value here, to be honest. The proposed algorithm introduces inconsistency between explicitly-written overloads and overloads formed from intersection, and breaks real code that people have certainly written where an earlier overload returns a more-specific type than the one it shadows. What do we get in return? It seems like we're trading one problem for another.
@MartinJohns
Then you say you have an intersection of them, that means the return type must also be an intersection, not a union.
Why "must" it be so? If it were intersection the return type of
(()=>string) & (()=>number)
would be never. That seems like proof by contradiction that the return type is not intersection. I don't see how we can make an exception for this one case, so it must apply to all cases.
@RyanCavanaugh
The proposed algorithm ... breaks real code that people have certainly written where an earlier overload returns a more-specific type than the one it shadows.
Like this:
interface A {
f(x:911):"emergency";
}
interface B {
f(x:number):number;
}
// current
declare const ab: A&B;
ab.f(911); // "emergency"
declare const ba: B&A;
ba.f(911); // "emergency"
// under proposal
// declare const ab: A&B;
// ab.f(911); // "emergency" | number
// declare const ba: B&A;
// ba.f(911); // "emergency" | number
I don't see the value here, to be honest. The proposed algorithm introduces inconsistency between explicitly-written overloads and overloads formed from intersection, and breaks real code that people have certainly written where an earlier overload returns a more-specific type than the one it shadows. What do we get in return? It seems like we're trading one problem for another.
In cases like the one above, the proposal would return "emergency" | number
which is wider.
Whereas current typescript returns "emergency"
for both A&B
, and B&A
, but it only works for B&A
because TypeScript has treated B&A
as an overload and reordered the members of overload, which comes at a cost for complex overloads - both in computation time and complexity/maintenence of logic.
For cases where the overloads are independent and not ordered, however, current algorithm chooses an arbitrary type that must be too narrow. Too narrow a type is much worse than too wide.
Why "must" it be so?
Because it's the only logical thing to do. A union doesn't make sense and isn't type safe. (()=>string) & (()=>number)
is assignable to (()=>string)
, so a caller would expect a string
. It's also assignable to (()=>number)
, so a caller would expect a number
. In some cases returning a string
, in others a number
(aka a union) when each respective type says it's always a string
or number
is just wrong.
And yes, the logical result is that the return type ends up being never
, because (()=>string) & (()=>number)
is a type that can't be fulfilled in JavaScript. You can't have a function that returns a string
and returns a number
at the same time, but that's what this type says it does.
@MartinJohns - An &
of two functions expresses members of a set of overload sequences, without expressing their order, and therefore includes all possible orders. Whatever overload order is chosen but particular implementation, at point of call one (or more in case of multi-matching) overload will be matched, and that overloads return type will be the output.
interface Y<T> {
g(f:()=>T):void
};
declare const yy:Y<string> | Y<number>;
yy.g; // (method) Y<T>.g(f: (() => string) & (() => number)): void
Here the argument f
to g
has to be an overload. The return type of f
will be either string
or number
, depending on the overload order,, which is unknown, so the type must be string|number
.
Of course this is a psychotic corner case, because both overload orders have illogical shadowing, so it would never be implemented as overload, but instead as a single function. The algorithm is strong if it works on such corners case, which would happen if this proposal were used.
Last, but not least, in example 1 of the original post, the current behavior is not never
but string
.
const rab = ab0.foo(); // actual string, expecting string | number
It is string
because it the overload {()=>string;()=>number}
is passed to chooseOverload
with args []
, and the first overload is returned.
@MartinJohns The following is at least a proof about the current behavior:
interface A0 {
foo(): string;
}
interface B0 {
foo(): number;
}
declare const test2: (A0 & B0) | (B0 & A0);
const r2 = test2.foo();
// ^? string | number
If, as you assert, both (A0 & B0)["foo"]
and (B0 & A0)["foo"]
returned never
, then test2.foo()
would obviously have to return never
, but it returns string|number
.
The point of this post is to claim that each case of (A0 & B0)["foo"]
and (B0 & A0)["foo"]
should return string|number
, and the fact that they instead return only string
and number
respectively is due to TypeScript currently treating &
-intersection incorrectly as an overload-join operator.
I don't see the value here, to be honest. The proposed algorithm introduces inconsistency between explicitly-written overloads and overloads formed from intersection, and breaks real code that people have certainly written where an earlier overload returns a more-specific type than the one it shadows. What do we get in return? It seems like we're trading one problem for another.
These are already inconsistent (#56951), and ReturnType
will give wrong results unless the intersections are in a particular order:
The intended thing for users to do is:
- Write their overloads
- 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
Unless people follow this rule,
ReturnType
can't do its job in a number of ways, including this one. Certainly for something like this that has zero runtime analog whatsoever, there's nothing meaningful for the type system to say about it.
I'd rather some order-independent behavior for intersection, especially given that the order-dependent behavior currently provided doesn't match that of function overloads.
I think this proposal as written is not tenable. The correct return type of overloads should be the intersection of the return types of applicable signatures.
That is, type F = ((x:X1)=>Y1) & ((x:X2)=>Y2)
should be inferred as:
x
satisfies X1
and X2
then Y1 & Y2
.x
satisfies X1
but NOT X2
then Y1
.x
satisfies X2
but NOT X1
then Y2
.x
satisfies X1 | X2
, and neither is a proper subtype of the other, then Y1 | Y2
.This means that you could still specify a most general overload, but get the benefits of more specific overloads.
example 1: ab0
and ba0
are both never
example 2: ab1.bar(2)
and ba1.bar(2)
are both never
example 3: rab21
and rba21
are both {a:string, b:string}
π Search Terms
57002
41874
intersection of functions, return type, incomplete, declaration order dependent
β Viability Checklist
β Suggestion
The return type of an intersection of functions should be at least complete and independent of the order of declaration of the functions.
π Motivating Example
Example 1: intersection of non-overload functions
Example 2: intersection of overload functions
Example 3: intersection of functions with object return types
Current algorithm:
g = g[0] & g[1] & ...
as though it were an ordered overload function{ g[0] ; g[1] ; ....}
.args
be the arguments.ReturnType<chooseOverload(g, args)>
The current algorithm treats
g
exactly as though it were an ordered overload function{ g[0] ; g[1] ; ....}
. Thereforeargs
can match at most one intersection memberg[i]
, resulting in an incomplete and declaration order dependent return type.Proposed algorithm # 1:
g = g[0] & g[1] & ...
be the intersection of functions.args
be the arguments.returnType = never
// initializeg[i]
ing
g[i]
is an overload functionreturnType = returnType | ReturnType<chooseOverload(g[i], args)>
args
extendsParameters<g[i]>
thenreturnType = returnType | ReturnType<g[i]>
returnType
This is expected to work for the above examples.
Proposed algorithm # 2:
Compared to the current algorithm, Proposed algorithm # 1 is a more expensive computation, but it is also complete and is not dependent on declaration order. If that computation is too expensive, then a simpler algorithm could be used:
i
ofReturnType<g[i]>
. Ifg[i]
is an overload function, it is calculated as the return type of the catch-all (a.k.a. cover) case for that overload sequence.Justification:
cover(g)
be the catch-all case (a.k.a. the cover) ofg
. i.e. for each parameter indexparamIndex
,Parameters<cover(g)>[paramIndex]
is the union overi
ofParameters<g[i]>[paramIdex]
, andReturnType<cover(g)>
is the union overi
ofReturnType<g[i]>
.cover(g)
is the smallest upper bound ofg
that can be represented with a single non-overload function.In the cases of examples 1 and 3, the answer wouldn't change, proposal # 2 is gives identical return type to that calculated with proposal # 1.
π» Use Cases
Getting accurate return types from intersections of functions.
When A and B are instances of a generic function, e.g.
then a workaround is to use the type
even though the type
AB
is wider than the intersection ofA
andB
.That workaround is similar in nature to using Proposed Algorithm # 2.