You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Narrowing lost in polymorphic function application #27259
I can point exactly to what causes this, because (at least for booleans, which were the motivator) it was intentional. In
typeXY='x'|'y';constx: XY='x';letx2=x;// Has type string
x's declared type is narrowed by it's assigned type of a fresh 'x', resulting in a fresh 'x' (since the narrowing type was fresh) which then widens on assignment to let. Take the same example for boolean:
constx: boolean=true;letx2=x;// Has type boolean
Without this rule, x2 would just be true. So do we want to remove the freshness-carries-through-narrowing change for all types? Keep it for just boolean?
Repeating my comment in #27042, the intuitive rule was always that once you add a type annotation to let, const or var declaration, values read from that variable are not fresh. But now they are unless the annotated type is a unit type.
constx1: 'x'='x';letz1=x1;// 'x'constx2: 'x'|'y'='x';letz2=x2;// Was 'x', now string
Hm, but the annotation actually wasn't why that was the case, strictly speaking. It was simply because the types we were narrowing by weren't fresh. If by chance you narrowed by a union of fresh types (because, eg, the union type is built up from conditional expressions) it would behave exactly like this already, even if it were annotated!
The reason I added the change was for consistency, because a union of fresh types like that is built for booleans all the time (while it's more rare for other literals) and consistiency between the declared and constructed forms of the types seemed desirable.
Hm, but the annotation actually wasn't why that was the case, strictly speaking.
As I said above, the core intuition was always that once a type annotation was present in the path that a value traveled, the value would loose its freshness. That's broken now in a way that I don't think I can explain.
I think we need to stick with the old rules. If they don't work for fresh booleans then maybe we don't need fresh booleans.
I think we need to stick with the old rules. If they don't work for fresh booleans then maybe we don't need fresh booleans.
It's more like the old rules are very unintuitive for booleans which previously widened everywhere.
As I said above, the core intuition was always that once a type annotation was present in the path that a value traveled, the value would loose its freshness. That's broken now in a way that I don't think I can explain.
We could choose preserve that intuition with a different change (ie, by actually explicitly having a rule governing it) - by explicitly stripping the freshness measured from an initializer of a statement with a declared type.
Ideally, when narrowing occurs, the type it narrowed from would be saved. Then, if it has to widen, it widens to the saved type.
const a : b = c; // a is of type c, but if it has to widen, it should widen to b
let d = a; // its fresh, so widen it, but instead widen it to b instead of the limited/arbitrary set of number/string/boolean/enum
I think disregarding ": b" when c is assigned to a, is rather confusing. In fact, if ": b" is completely ignored, it shouldn't even be allowed / it should be warned of by the linter, as writing code that becomes a noop is rarely expected behavior. Even in the original functionality d would be of type "c" - not what the user likely intended. Most think of constants by their type and value, and dont think of a constant's type being the value itself (As in C/C++ const int a = 5; where a is logically an integer. 5 can be used by the compiler and linting. auto b = a; still keeps b an integer). I haven't contributed to this repo but I'm sure my suggestion is a rather large overhaul anyway; at least it's food for thought.
let e : b = generateRandomB();
const a : b = c; // a is of type b with guaranteed value c
let d = a; // mutables inherit type and value as well, type cannot be widened, but value may be widened for mutables. Value may not be widened for consts, clearly.
somefunc(d) // can use the fact that d has value c for static analysis, and it will work if somefunc accepts type c but does not accept type b. Very useful if somefunc wants a special case of b, just like a narrowing if statement
let d = e // Valid, and 'd' now has type 'b' and no guaranteed value. Older version of TS mark this as invalid since 'd' is of type 'c', which doesnt fit what the user is thinking in his mind. Newest TS says its valid, because d is superwidened to the primitive of 'c', which is likely wider than 'b'; still not ideal.
I think this logic is a lot simpler than having literals being types, and causing an overload for what 'type' represents. On the same token,
const a = "hey"; // type string, value "hey"
let b = a; // b has type string, and value "hey"
b = randomString(); // b has type string, and unknown value
works perfectly (Which is what all this freshness was designed to achieve in the first place, as far as I understand - no guarantees Im understanding this well however).
Anders Hejlsberg (@ahejlsberg) here's my summing of the situation. It feels like we're at a local maximum at least, but there's one example where it seems there's some disagreement about the desired behavior:
functionf1(){letb=true;letobj={ b };// Desired: OK// 3.0: OK// 3.1 as-is: OK// 3.1 minus widening propagation: errorobj.b=false;}functionf2(){typeElement=(string|false);typeElementOrArray=Element|Element[];letel: Element=nullasany;letarr: Element[]=nullasany;letelOrA: ElementOrArray=nullasany;// Desired/actual: All OKleta1: ElementOrArray=el;leta2: ElementOrArray=arr;leta3: ElementOrArray=[el];leta4: ElementOrArray=Array.isArray(elOrA) ? elOrA : [elOrA];// Desired: OK// 3.0: Error// 3.1: OKleta5: ElementOrArray=[...Array.isArray(elOrA) ? elOrA : [elOrA]];}functionf3(){typeXY='x'|'y';constx: XY='x';letx2=x;// Desired: OK (up for debate?)// 3.0: Error// 3.1 as-is: OKx2='y';// Desired/actual: All OKletx3: XY=x;x3='y';}functionf4(){constx: boolean=true;letx1=x;// Desired: OK// 3.0: OK// 3.1: OK// 3.1 minus widening propagation: errorx1=false;}functionf5(){typeXY='x'|'y';letarr: XY[]=['x'];arr=['y'];// Desired: OK// Error in all extant branchesarr=[...['y']];}
The widening approach breaks all efforts put into excluding union cases and prevent relying on the type checker for precise exhaustive cases checking.
It would hugely lowers the value of typescript.
will this fix a huge issue in typing one of our tools?
class Box<A> {
constructor(readonly value: A) { }
orElse<B>(other: B): A | B {
return !this.value ? other : this.value as any;
}
}
// note the type here is "foo" | "bar" this is what i want.
const test = new Box("foo" as "foo").orElse("bar") // "foo" | "bar"
// note the type here is never[] | string[] what i want is string[]
// so lets change box for better inference
const test2 = new Box(["foo"]).orElse([]) // never[] | string[]
class Box2<A> {
constructor(readonly value: A) { }
orElse<B>(other: B): B extends never[] ? A : A | B {
return !this.value ? other : this.value as any;
}
}
// string and not "foo" | "bar" its narrowed
const test3 = new Box2("foo" as "foo").orElse("bar") // string
// this works now, just comes out as string[] but at the cost of inference of first example.
const test4 = new Box2(["foo"]).orElse([]) // string[]
Activity
[-]Narrowing lost is polymorphic function application[/-][+]Narrowing lost in polymorphic function application[/+]ahejlsberg commentedon Sep 21, 2018
A smaller repro:
We definitely shouldn't be widening the type of
x2tostring.ahejlsberg commentedon Sep 21, 2018
Wesley Wigham (@weswigham) Ryan Cavanaugh (@RyanCavanaugh) We should look at this one right away.
ahejlsberg commentedon Sep 21, 2018
Timing seems to indicate this is an effect of #27042.
weswigham commentedon Sep 21, 2018
I can point exactly to what causes this, because (at least for booleans, which were the motivator) it was intentional. In
x's declared type is narrowed by it's assigned type of a fresh 'x', resulting in a fresh 'x' (since the narrowing type was fresh) which then widens on assignment tolet. Take the same example forboolean:Without this rule,
x2would just betrue. So do we want to remove the freshness-carries-through-narrowing change for all types? Keep it for just boolean?ahejlsberg commentedon Sep 21, 2018
Wesley Wigham (@weswigham) See my comment here.
ahejlsberg commentedon Sep 21, 2018
Repeating my comment in #27042, the intuitive rule was always that once you add a type annotation to let, const or var declaration, values read from that variable are not fresh. But now they are unless the annotated type is a unit type.
I find it hard to construct a rationale for that.
weswigham commentedon Sep 21, 2018
Hm, but the annotation actually wasn't why that was the case, strictly speaking. It was simply because the types we were narrowing by weren't fresh. If by chance you narrowed by a union of fresh types (because, eg, the union type is built up from conditional expressions) it would behave exactly like this already, even if it were annotated!
weswigham commentedon Sep 21, 2018
The reason I added the change was for consistency, because a union of fresh types like that is built for booleans all the time (while it's more rare for other literals) and consistiency between the declared and constructed forms of the types seemed desirable.
ahejlsberg commentedon Sep 21, 2018
As I said above, the core intuition was always that once a type annotation was present in the path that a value traveled, the value would loose its freshness. That's broken now in a way that I don't think I can explain.
I think we need to stick with the old rules. If they don't work for fresh booleans then maybe we don't need fresh booleans.
weswigham commentedon Sep 21, 2018
It's more like the old rules are very unintuitive for booleans which previously widened everywhere.
We could choose preserve that intuition with a different change (ie, by actually explicitly having a rule governing it) - by explicitly stripping the freshness measured from an initializer of a statement with a declared type.
npip99 commentedon Sep 21, 2018
Ideally, when narrowing occurs, the type it narrowed from would be saved. Then, if it has to widen, it widens to the saved type.
I think disregarding ": b" when c is assigned to a, is rather confusing. In fact, if ": b" is completely ignored, it shouldn't even be allowed / it should be warned of by the linter, as writing code that becomes a noop is rarely expected behavior. Even in the original functionality d would be of type "c" - not what the user likely intended. Most think of constants by their type and value, and dont think of a constant's type being the value itself (As in C/C++ const int a = 5; where a is logically an integer. 5 can be used by the compiler and linting. auto b = a; still keeps b an integer). I haven't contributed to this repo but I'm sure my suggestion is a rather large overhaul anyway; at least it's food for thought.
I think this logic is a lot simpler than having literals being types, and causing an overload for what 'type' represents. On the same token,
works perfectly (Which is what all this freshness was designed to achieve in the first place, as far as I understand - no guarantees Im understanding this well however).
RyanCavanaugh commentedon Sep 21, 2018
Anders Hejlsberg (@ahejlsberg) here's my summing of the situation. It feels like we're at a local maximum at least, but there's one example where it seems there's some disagreement about the desired behavior:
sledorze commentedon Sep 21, 2018
The widening approach breaks all efforts put into excluding union cases and prevent relying on the type checker for precise exhaustive cases checking.
It would hugely lowers the value of typescript.
sledorze commentedon Sep 24, 2018
thanks Wesley Wigham (@weswigham) !
ShanonJackson commentedon Dec 15, 2018
will this fix a huge issue in typing one of our tools?