Skip to content
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

Return types of intersection of functions are incomplete and depend on order of declaration - an algorithm to fix it. #57095

Open
6 tasks done
craigphicks opened this issue Jan 19, 2024 · 9 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@craigphicks
Copy link

craigphicks commented Jan 19, 2024

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

interface A0 {
    foo(): string;
}

interface B0 {
    foo(): number;
}

declare const ab0: A0 & B0;
declare const ba0: A0 & B0;

const rab = ab0.foo(); // actual string, expecting string | number
const rba = ba0.foo(); // actual number, expecting string | number

Example 2: intersection of overload functions

interface A1 {
    bar(x:1): 1;
    bar(x:2): 2;
}
interface B1 {
    bar(x:2): "2";
    bar(x:3): "3";
}

declare const ab1: A1 & B1;

const rab11 = ab1.bar(1); // actual 1, expecting 1
const rab12 = ab1.bar(2); // actual 2, expecting 2 | "2"
const rab13 = ab1.bar(3); // actual 3, expecting 3

declare const ba1: B1 & A1;

const rba11 = ba1.bar(1); // actual 1, expecting 1
const rba12 = ba1.bar(2); // actual "2:, expecting 2 | "2"
const rba13 = ba1.bar(3); // actual 3, expecting 3

Example 3: intersection of functions with object return types

interface A2 {
    bar(): {a: string};
}
interface B2 {
    bar(): {b: string};
}

declare const ab2: A2 & B2;
declare const ba2: B2 & A2;

const rab21 = ab2.bar(); // actual: {a: string}, expecting: {a: string} | {b: string}
const rba21 = ba2.bar(); // actual: {b: string}, expecting: {a: string} | {b: string}

Current algorithm:

  • Treat g = g[0] & g[1] & ... as though it were an ordered overload function { g[0] ; g[1] ; ....}.
  • Let args be the arguments.
  • return ReturnType<chooseOverload(g, args)>

The current algorithm treats g exactly as though it were an ordered overload function { g[0] ; g[1] ; ....}. Therefore args can match at most one intersection member g[i], resulting in an incomplete and declaration order dependent return type.

Proposed algorithm # 1:

  • Let g = g[0] & g[1] & ... be the intersection of functions.
  • Let args be the arguments.
  • returnType = never // initialize
  • for g[i] in g
    • if g[i] is an overload function
      • returnType = returnType | ReturnType<chooseOverload(g[i], args)>
    • else if args extends Parameters<g[i]> then
      • returnType = returnType | ReturnType<g[i]>
  • return 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:

  • The return type is union over i of ReturnType<g[i]>. If g[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:

  • Let cover(g) be the catch-all case (a.k.a. the cover) of g. i.e. for each parameter index paramIndex, Parameters<cover(g)>[paramIndex] is the union over i of Parameters<g[i]>[paramIdex], and ReturnType<cover(g)> is the union over i of ReturnType<g[i]>. cover(g) is the smallest upper bound of g 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.

What workarounds are you using in the meantime?

When A and B are instances of a generic function, e.g.

type G<T> = { bar: <T>() => T };
type A = G<{a:string}>;
type B = G<{b:string}>;

then a workaround is to use the type

type AB = G<{a:string, b:string}>;

even though the type AB is wider than the intersection of A and B.

That workaround is similar in nature to using Proposed Algorithm # 2.

@MartinJohns
Copy link
Contributor

MartinJohns commented Jan 19, 2024

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.

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Jan 19, 2024

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.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Jan 19, 2024
@craigphicks
Copy link
Author

craigphicks commented Jan 20, 2024

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

@craigphicks
Copy link
Author

craigphicks commented Jan 20, 2024

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

@MartinJohns
Copy link
Contributor

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.

@craigphicks
Copy link
Author

craigphicks commented Jan 20, 2024

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

@craigphicks
Copy link
Author

craigphicks commented Jan 21, 2024

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

@rotu
Copy link

rotu commented Jan 22, 2024

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.

@rotu
Copy link

rotu commented Jan 22, 2024

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:

  • if x satisfies X1 and X2 then Y1 & Y2.
  • if x satisfies X1 but NOT X2 then Y1.
  • if x satisfies X2 but NOT X1 then Y2.
  • if x satisfies X1 | X2, and neither is a proper subtype of the other, then Y1 | Y2.
  • otherwise a type error.

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}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

4 participants