microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
101.02k stars 12.49k forks source link

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

Open craigphicks opened 9 months ago

craigphicks commented 9 months ago

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

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:

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:

Justification:

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

RyanCavanaugh commented 9 months ago

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.

craigphicks commented 9 months ago

@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 commented 9 months ago

@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 commented 9 months ago

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 commented 9 months ago

@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 commented 9 months ago

@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 commented 9 months ago

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 commented 9 months ago

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:

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}