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

Proposal: better type narrowing in overloaded functions through overload set pruning #22609

Open
lodo1995 opened this issue Mar 15, 2018 · 16 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@lodo1995
Copy link

TypeScript Version: 2.7.2

Search Terms: type inference, type narrowing, overloads

Code

function bar(selector: "A", val: number): number;
function bar(selector: "B", val: string): string;
function bar(selector: "A" | "B", val: number | string): number | string
{
    if (selector === "A")
    {
        const the_selector = selector;  // "A"
        const the_val = val;            // number | string but should be just number
        return "foo";                   // should give error: when selector is "A" should return a number
    }
    else
    {
        const the_selector = selector;  // "B"
        const the_val = val;            // number | string but should be just string
        return 42;                      // should give error: when selector is "B" should return a string
    }
}

Issue

The snippet above shows a limitation of the current type inference implementation in TypeScript.
As can be clearly seen from the overloaded declarations, when selector === "A" we have val: number and we must return a number, while when selector === "B" we have val: string and we must return a string. But the type-checker, although able to understand that the_selector can only be "A" in the if block and only "B" in the else block, still types the_val as number | string in both blocks and allows us to return a string when selector === "A" and a number when selector === "B".

Possible Solution

In the implementation of an overloaded function, when the type-checker decides to narrow the type of a parameter (as often happens in if-else blocks), it should also remove the overloads that don't match the narrowed parameter type from the overload set, and use the pruned overload set to narrow the types of the other parameters.

Playground Link: https://www.typescriptlang.org/play/#src=%0Afunction%20bar(selector%3A%20%22A%22%2C%20val%3A%20number)%3A%20number%3B%0Afunction%20bar(selector%3A%20%22B%22%2C%20val%3A%20string)%3A%20string%3B%0Afunction%20bar(selector%3A%20%22A%22%20%7C%20%22B%22%2C%20val%3A%20number%20%7C%20string)%3A%20number%20%7C%20string%0A%7B%0A%20%20%20%20if%20(selector%20%3D%3D%3D%20%22A%22)%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20const%20the_selector%20%3D%20selector%3B%0A%20%20%20%20%20%20%20%20const%20the_val%20%3D%20val%3B%0A%20%20%20%20%20%20%20%20return%20%22foo%22%3B%0A%20%20%20%20%7D%0A%20%20%20%20else%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20const%20the_selector%20%3D%20selector%3B%0A%20%20%20%20%20%20%20%20const%20the_val%20%3D%20val%3B%0A%20%20%20%20%20%20%20%20return%2042%3B%0A%20%20%20%20%7D%0A%7D

@lodo1995 lodo1995 changed the title Feature request: better type narrowing in overloaded functions through overload pruning Feature request: better type narrowing in overloaded functions through overload set pruning Mar 15, 2018
@lodo1995 lodo1995 changed the title Feature request: better type narrowing in overloaded functions through overload set pruning Proposal: better type narrowing in overloaded functions through overload set pruning Mar 15, 2018
@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript In Discussion Not yet reached consensus labels Mar 15, 2018
@krryan
Copy link

krryan commented Mar 15, 2018

Absolutely agreed, this kind of thing would go a long way towards making overloaded functions a lot safer to use. But it might also be a breaking change: overloads can be used to assert/claim type relationships that TS cannot see. Enforcing particular rules may break attempts to use them to circumvent and/or augment those rules in the first place. But then, that usage of overloads always seemed dubious to me...

Also, related to #21879, which is requesting a similar thing for conditional types rather than overloads. The same problems raised there may also apply here.

@lodo1995
Copy link
Author

lodo1995 commented Mar 16, 2018

I'm not sure the problems expressed in #21879 could be a real concern here, as we are not directly infering a generic type instantiation from another. To mirror the example that @DanielRosenwasser gave on #21879:

function compare(a: string, b: number): number;
function compare<T>(a: T, b: T): number;
function compare(a, b)
{
    switch (typeof a)
    {
        case "string":    // second overload removed, b is number
        case "number":    // first overload removed, b is T
        default:          // first overload removed, b is T
    }
}

As you can see, here we are not infering b: number from a: number and compare: function<T>(a: T, b: T). So, we are not narrowing a parameter more than what specified in the overload declaration. We are just narrowing it to the overload declarations that are applicable to the current block.

@Tiedye
Copy link

Tiedye commented Aug 28, 2018

It would be fantastic if we could do something like this:

function f(a: number): void;
function f(a: string, b: string): void;
function f(a, b) {
  if (typeof a === 'number') {
    // `a` is a string and `b` is thus `undefined`
  }
}

The implementation would not be considered one of the overload signatures, and thus the arguments can be properly narrowed.

To preserve compatibility with implicit any, if the feature was only available when noImplicitAny is enabled it would be completely backwards compatible as it would only kick in when there were no type arguments on an overloaded constructor/method/function (right now this is an error).

@tarwich
Copy link

tarwich commented Apr 6, 2019

The solution from @Tiedye basically makes this feature opt-in, which would serve for gradual adoption as people need / want it.

@miginmrs
Copy link

miginmrs commented Jan 2, 2020

Here is a type-safe workaround by the time this feature is implemented:

type K = string | number | symbol;
const keyIs = <k extends K, C, T extends { [i in k]: unknown; }, V extends { [i in k]: C; }>(
  args: T | V, k: k, c: C): args is V => args[k] === c;
type Return<F, arg extends unknown[], R> = F extends { (...args: arg): R; } ? R : never;

function bar(selector: "A", val: number): number;
function bar(selector: "B", val: string): string;
function bar(...args: ["A", number] | ["B", string]): number | string {
  if (keyIs(args, 0, "A" as const)) {
    let _return: Return<typeof bar, typeof args, number>;
    const [selector, val] = args;
    const the_selector = selector;  // "A"
    const the_val = val;            // just number
    return _return = 42;            // when selector is "A" it returns a number, otherwise it raises an error
  }
  else {
    let _return: Return<typeof bar, typeof args, string>;
    const [selector, val] = args;
    const the_selector = selector;  // "B"
    const the_val = val;            // just string
    return _return = "foo";         // when selector is "B" it returns a string, otherwise it raises an error
  }
}

@runspired
Copy link

@RyanCavanaugh what was the outcome of the discussions on this? I troll myself regularly wondering why my overload isn't narrowing within my switch statement 😭

@SukkaW
Copy link

SukkaW commented Jun 29, 2021

Really? Two years have passed and the issue is still being ignored?

I only see pro here and no one even mentioning any con.

@GodBleak
Copy link

GodBleak commented Apr 23, 2022

Control flow analysis for dependent parameters(#47190) is an acceptable workaround if all you're wanting is TS to narrow the types of the args once they match a case. However as far as I can tell you're unable to define a return type for each case.
It would look like this:

type Bar = (...args: ["A", number] | ["B", string]) =>  number | string

const bar:Bar = (selector, val) => {
    if (selector === "A")
        {
            const the_selector = selector;  // "A"
            const the_val = val;            // is number
            return "foo";                   // won't give error unfortunately
        }
    else
        {
            const the_selector = selector;  // "B"
            const the_val = val;            // is string
            return 42;                      // won't give error unfortunately
        }
}

I'd still rather have overloads work as @lodo1995 described, but this works for me in my case as the return type is the same in both cases and I can avoid this craziness:

  return (val as number).toFixed(2);

@DaviDevMod
Copy link

DaviDevMod commented Apr 25, 2022

Until the feature is implemented, the cleanest solution is to use a discriminated union type.

Example with overloads (Cutlery is not narrowed):

type Food = 'TANGERINE' | 'PASTA';
type Cutlery = 'FORK' | 'SPOON';

function eat(food: 'TANGERINE'): void;
function eat(food: 'PASTA', cutlery: Cutlery): void;
function eat(food: Food, cutlery?: Cutlery) {
  if (food === 'TANGERINE') cutlery;
  else cutlery;
}

eat('TANGERINE');

Example with discriminated union (Cutlery is narrowed):

type Food = 'TANGERINE' | 'PASTA';
type Cutlery = 'FORK' | 'SPOON';

type Meal = { food: 'TANGERINE'; cutlery: never } | { food: 'PASTA'; cutlery: Cutlery };

function eat({ food, cutlery }: Meal) {
  if (food === 'TANGERINE') cutlery;
  else cutlery;
}

eat({ food: 'TANGERINE' } as Meal);

The only "drawback" is that the function now expects a single object containing all the arguments.
For me it's a fair price to pay for having a clean and reliable narrowing.

EDIT: It turns out this method is not clean and reliable.
Is not reliable because you can't call eat({ food: 'TANGERINE' }); without the assertion as Meal, and yet with the assertion you are allowed to call eat({ food: 'PASTA' } as Meal);
You can get rid of the assertion doing something like this but that's not clean.

@DaviDevMod
Copy link

DaviDevMod commented Apr 25, 2022

To avoid making that wall of a comment even longer, I post this useful information here.

The problems outlined at the end of my previous comment can be fixed by making the never properties optional.

Check the playground.

type Food = 'TANGERINE' | 'PASTA';
type Cutlery = 'FORK' | 'SPOON';

type Meal = { food: 'TANGERINE'; cutlery?: never } | { food: 'PASTA'; cutlery: Cutlery };

function eat({ food, cutlery }: Meal) {
  if (food === 'TANGERINE') cutlery;
  else cutlery;
}

eat({ food: 'TANGERINE' });
eat({ food: 'PASTA', cutlery: 'FORK'});

eat({ food: 'TANGERINE', cutlery: 'SPOON' });  // error
eat({ food: 'PASTA' });  // error

I feel like I'm spamming, but I trust that one day this will be helpful to someone.

@runspired
Copy link

The pattern encouraged here as a work around has very poor performance characteristics for runtime code and basically should not be used for hot-paths. The allocation of extra wrapper objects so that we can narrow off of a union of interfaces pays a double cost, both for the allocation but also for the inevitable GC interruption.

@DaviDevMod
Copy link

@runspired I totally agree, it's always a shame to sacrifice runtime performance to address TS shortcomings.

@surdu
Copy link

surdu commented Sep 12, 2022

Is the following a problem resulting from this issue, or is it something else?

interface TestType {
    m1(): string;
    m1(arg1: number): number;
}

function mytest(obj: TestType, testNumber?: number) {
    obj.m1(testNumber);
}

As you can see in the Playground example, we have an error when calling m1 complaining that Type 'undefined' is not assignable to type 'number'.

But m1 has an overload that accepts undefined as argument.

@krryan
Copy link

krryan commented Sep 12, 2022

@surdu

Is the following a problem resulting from this issue, or is it something else?

No, it’s not this problem or any TS problem—you misunderstand what your code is saying.

But m1 has an overload that accepts undefined as argument.

False. It does not have any overload that accepts undefined—it has an overload that accepts nothing, or an overload that accepts number. obj.m1() and obj.m1(undefined) are not the same thing.

Imagine this situation:

const foo: TestType = {
    m1: function() {
        if (arguments.length === 1) return 'not a circle';

        const arg1: number = arguments[0];
        return Math.pow(arg1, 2) * Math.PI;
    },
};

(This is a terrible implementation of this signature, and you should almost never use arguments in Typescript, but nonetheless you can.)

According to your interface, const arg1: number = arguments[0]; should be safe when arguments.length >= 1.

But if you call foo.m1(undefined), then arguments.length >= 1 would be true, but const arg1: number = arguments[0]; would be incorrect. Then we’d be calling Math.pow(undefined, 2) * Math.PI—that yields NaN, for the record, but I had to test it to find out, and it certainly isn’t the string you were expecting.

In short, foo.m1() and foo.m1(undefined) are different—and your signature only allows the former.

The correct way to write TestType is

interface TestType {
    m1(arg1?: undefined): string;
    m1(arg1: number): number;
}

Now it’s clear that we can’t rely on the number of arguments to determine the type of arg1 or the correct return type—we could get foo.m1(undefined) and our implementation has to account for that.

(Also, for the record, this signature is weird, and probably a code smell. Even with my improved version of the signature, Typescript won’t recognize any function as having that signature—to assign m1, you’ll have to use as TestType['m1'] no matter what you do. That problem may well be related to this issue. But with my version, calling m1 will work as expected.)

@surdu
Copy link

surdu commented Sep 14, 2022

@krryan Thank you for the clarification. That makes much more sense now.

As for the signature, it is a simplified version of what I found in the crypto module from NodeJS

@axmmisaka
Copy link

Control flow analysis for dependent parameters(#47190) is an acceptable workaround if all you're wanting is TS to narrow the types of the args once they match a case. However as far as I can tell you're unable to define a return type for each case. It would look like this:

type Bar = (...args: ["A", number] | ["B", string]) =>  number | string

const bar:Bar = (selector, val) => {
    if (selector === "A")
        {
            const the_selector = selector;  // "A"
            const the_val = val;            // is number
            return "foo";                   // won't give error unfortunately
        }
    else
        {
            const the_selector = selector;  // "B"
            const the_val = val;            // is string
            return 42;                      // won't give error unfortunately
        }
}

I'd still rather have overloads work as @lodo1995 described, but this works for me in my case as the return type is the same in both cases and I can avoid this craziness:

  return (val as number).toFixed(2);

Just chiming in to mention what won't work and mention that I recently also trolled myself by thinking overloading/workaround I thought would work could narrow type.

We have a function that takes two arguments, src and dst, and the signature should be either-or:
Originally, we have code that looks like

      if (src instanceof CallerPort && dst instanceof CalleePort) {
        // do something
      } else if (src instanceof IOPort && dst instanceof IOPort) {
        // do something else
      } else { // error handling
      }

Wishing to make it a switch-case and eliminate the possibility of passing in non-matching arguments, I changed the function signature to be

    public connect<R, S extends R>(src: IOPort<S>, dst: IOPort<R>): void;
    public connect<A extends T, R, T, S extends R>(
      src: CallerPort<A, R>,
      dst: CalleePort<T, S>
    ): void;

    public connect<A extends T, R, T, S extends R>(
      src: CallerPort<A, R> | IOPort<S>,
      dst: CalleePort<T, S> | IOPort<R>
    ): void

But in the implementation it won't narrow type. Consequently I tried this modified version of a workaround:

    public connect<A extends T, R, T, S extends R>(
      ...args: [CallerPort<A, R>, CalleePort<T, S>] | [IOPort<S>, IOPort<R>]
    ): void {
      const [src, dst] = args;

But inside of if (src instanceof CallerPort), dst is not narrowed.

This really isn't a huge problem for us as it's really 3 lines of additional code, but I really do think this would be a wonderful feature to add.

axmmisaka pushed a commit to lf-lang/reactor-ts that referenced this issue Oct 2, 2023
1. Implemented ConnectablePort that can be used as `Variable`
2. Refactored ConnectablePort with function overloading at the same time to facilitate better type check (but due to TS limitation there's no type narrowing, see microsoft/TypeScript#22609)
3. Made tests conform to the new connection API
axmmisaka pushed a commit to lf-lang/reactor-ts that referenced this issue Oct 2, 2023
1. Implemented ConnectablePort that can be used as `Variable`
2. Refactored ConnectablePort with function overloading at the same time to facilitate better type check (but due to TS limitation there's no type narrowing, see microsoft/TypeScript#22609)
3. Made tests conform to the new connection API
axmmisaka pushed a commit to lf-lang/reactor-ts that referenced this issue Oct 18, 2023
1. Implemented ConnectablePort that can be used as `Variable`
2. Refactored ConnectablePort with function overloading at the same time to facilitate better type check (but due to TS limitation there's no type narrowing, see microsoft/TypeScript#22609)
3. Made tests conform to the new connection API
axmmisaka pushed a commit to lf-lang/reactor-ts that referenced this issue Dec 13, 2023
1. Implemented ConnectablePort that can be used as `Variable`
2. Refactored ConnectablePort with function overloading at the same time to facilitate better type check (but due to TS limitation there's no type narrowing, see microsoft/TypeScript#22609)
3. Made tests conform to the new connection API
lhstrh pushed a commit to lf-lang/reactor-ts that referenced this issue Oct 12, 2024
1. Implemented ConnectablePort that can be used as `Variable`
2. Refactored ConnectablePort with function overloading at the same time to facilitate better type check (but due to TS limitation there's no type narrowing, see microsoft/TypeScript#22609)
3. Made tests conform to the new connection API
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests