Repository navigation
Proposal: better type narrowing in overloaded functions through overload set pruning #22609
Description
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.
Activity
[-]Feature request: better type narrowing in overloaded functions through overload pruning[/-][+]Feature request: better type narrowing in overloaded functions through overload set pruning[/+][-]Feature request: better type narrowing in overloaded functions through overload set pruning[/-][+]Proposal: better type narrowing in overloaded functions through overload set pruning[/+]krryan commentedon 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 commentedon 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 Daniel Rosenwasser (@DanielRosenwasser) gave on #21879:
As you can see, here we are not infering
b: numberfroma: numberandcompare: 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 commentedon Aug 28, 2018
It would be fantastic if we could do something like this:
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
noImplicitAnyis 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 commentedon Apr 6, 2019
The solution from Daniel Whitney (@Tiedye) basically makes this feature opt-in, which would serve for gradual adoption as people need / want it.
miginmrs commentedon Jan 2, 2020
Here is a type-safe workaround by the time this feature is implemented:
runspired commentedon May 27, 2021
Ryan Cavanaugh (@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 commentedon 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 commentedon 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:
I'd still rather have overloads work as Lodovico Giaretta (@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:
DaviDevMod commentedon Apr 25, 2022
Until the feature is implemented, the cleanest solution is to use a discriminated union type.
Example with overloads (
Cutleryis not narrowed):Example with discriminated union (
Cutleryis narrowed):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 assertionas Meal, and yet with the assertion you are allowed to calleat({ food: 'PASTA' } as Meal);You can get rid of the assertion doing something like this but that's not clean.
DaviDevMod commentedon 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
neverproperties optional.Check the playground.
I feel like I'm spamming, but I trust that one day this will be helpful to someone.
runspired commentedon Aug 29, 2022
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 commentedon Aug 29, 2022
Chris Thoburn (@runspired) I totally agree, it's always a shame to sacrifice runtime performance to address TS shortcomings.
surdu commentedon Sep 12, 2022
Is the following a problem resulting from this issue, or is it something else?
As you can see in the Playground example, we have an error when calling
m1complaining thatType 'undefined' is not assignable to type 'number'.But
m1has an overload that acceptsundefinedas argument.krryan commentedon Sep 12, 2022
Nicu (@surdu)
No, it’s not this problem or any TS problem—you misunderstand what your code is saying.
False. It does not have any overload that accepts
undefined—it has an overload that accepts nothing, or an overload that acceptsnumber.obj.m1()andobj.m1(undefined)are not the same thing.Imagine this situation:
(This is a terrible implementation of this signature, and you should almost never use
argumentsin Typescript, but nonetheless you can.)According to your interface,
const arg1: number = arguments[0];should be safe whenarguments.length >= 1.But if you call
foo.m1(undefined), thenarguments.length >= 1would be true, butconst arg1: number = arguments[0];would be incorrect. Then we’d be callingMath.pow(undefined, 2) * Math.PI—that yieldsNaN, for the record, but I had to test it to find out, and it certainly isn’t thestringyou were expecting.In short,
foo.m1()andfoo.m1(undefined)are different—and your signature only allows the former.The correct way to write
TestTypeisNow it’s clear that we can’t rely on the number of arguments to determine the type of
arg1or the correct return type—we could getfoo.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 useas TestType['m1']no matter what you do. That problem may well be related to this issue. But with my version, callingm1will work as expected.)surdu commentedon Sep 14, 2022
Kevin Ryan (@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 commentedon Sep 18, 2023
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,
srcanddst, and the signature should be either-or:Originally, we have code that looks like
Wishing to make it a switch-case and eliminate the possibility of passing in non-matching arguments, I changed the function signature to be
But in the implementation it won't narrow type. Consequently I tried this modified version of a workaround:
But inside of
if (src instanceof CallerPort),dstis 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.
Implemented connectable port
Implemented connectable port
Implemented connectable port
Implemented connectable port
Implemented connectable port