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
Incorrect type inferred when accessing nested tuples by chained indexes #45686
typeKeysAndValues<K,V>=[[K[],V[]]];typeJustValues<V>=[V[]];functionborked<K,V>(data: KeysAndValues<K,V>|JustValues<V>): void{if(Array.isArray(data[0][0])){constresult=data[0][0][1];}else{constresult1=data[0][1];constresult2=data[0][0];// INCORRECT: V}}functionworking<K,V>(data: KeysAndValues<K,V>|JustValues<V>): void{if(Array.isArray(data[0][0])){constresult=data[0][0][1];}else{constrow=data[0];// the magic differenceconstresult1=row[1];constresult2=row[0];// CORRECT: V | K[]}}
🙁 Actual behavior
For some reason, in the function borked, above, accessing data[0][0] infers the incorrect type of V, when it should be V | K[]. If you do what is in the function working, above, and pull data[0] out into a variable (row), then row[0] will result in the correct type of V | K[]
🙂 Expected behavior
result2 should be of type V | K[] in both the working and borked functions in the example.
I think my eyes are just tripping over all the square brackets, but can you explain why you expect your expected behavior? Here’s what I see:
data[0][0] is K[] | V. No argument here.
Asking if that type is an array narrows it to K[]. This is unsound because it ignores the possibility that V itself is instantiated with an array type. Every other type that follows from this flawed assumption is technically wrong, the first of which is result as K in both functions. It should arguably be unknown, but that would be a bummer.
In working, I think you just defeat the narrowing logic by grabbing a reference to a higher-up property than the one that was narrowed. (This will possibly be “fixed” by Type guard by deep property #38839.)
So, I think I agree with your conclusion (on technical grounds, but this is a known/desired unsoundness that we really don't want to get rid of without negated types), but I wonder if you used the same logic to get there, because the easier way to demonstrate my conclusion is just by saying that result should be unknown in both functions.
This is a minimal reproduction of a situation a co-worker hit. Basically this is some deserialized nonsense from a server and he wanted to add some type safety around it. (I actually pointed out to him that one type is a superset of the other) Either way, it seems like a strange technical error. I just wanted to bring it to your attention. I'm not sure how else this could manifest or even if it does. 🤷♂️
Specifically, I don't necessarily agree with the logic above, It's just that I saw the strange type result and I thought I would report it.
Well, ignore my explanation for the moment. Can you explain why you expected your expected behavior? The flaw I pointed out in my explanation is fairly subtle, and if I take off my pedantic hat, the actual behavior looks correct to me.
Honestly, the structure/logic of the code doesn't really matter to me, that's just copy pasta from the code I was helping look at. It's the second part, and how it's effected by the Array.isArray check.
The type system can't guarantee that the V generic won't be an array. So the Array.isArray check can't possibly narrow anything. If V is a known type or extends some non-Array, then it can absolutely narrow it down, but there's no constraints in this code.
Since data[0] is V[] | [K[], V[]], Then I'd think data[0][0] should always be V | K[]. Why would pulling data[0] into it's own variable make a difference?
typeKeysAndValues<K,V>=[[K[],V[]]];typeJustValues<V>=[V[]];functionborked<K,V>(data: KeysAndValues<K,V>|JustValues<V>): void{if(!Array.isArray(data[0][0])){// This shouldn't matter, because you can't guarantee// that V is or isn't an Array.consta=data[0][0];// INCORRECT: V}constb=data[0][0];// CORRECT: V | K[]}
Put another way: This is only a problem generics, because generics could be arrays -- or anything else -- in this case. If you add hard types, it doesn't have any issues, as the Array.isArray actually does make a bit of difference, as you can see in this playground
Ok right, we’re on the same page then. Because we don’t have negated types, there are all kinds of places we make unsound assumptions about what a type parameter is not. Consider an analogous example:
asyncfunctionf<Textends{id: string}>(x: T|Promise<string>){constawaited=awaitx;// T | string;}
Here we assume that await cannot have an effect on the type T because T does not look like a Promise, so the await should just unwrap the Promise<string> or no-op on the T. But T is absolutely allowed to be instantiated with something Promise-like, as long as that Promise-like type also has an id: string property. Because Tmight be Promise-like, the only sound type for awaited is unknown, because if T happens to be Promise-like, we have no idea what type it will resolve to. The only way to square this circle is using a negated type as a constraint for T as a way to say “has an id and does not have a callable then.” But we have no way to express that, so we have to choose between being correct and being useful, and we choose utility.
The array case is the same. It’s often useful, though incorrect, to assume that V is not an array. If you know that it really might be in practice, you need to come up with some way to defeat the narrowing. One way might be to constrain it to something like string | number | boolean | object | unknown[] to make the compiler deal with the fact that it really might be array-like.
Why would pulling data[0] into it's own variable make a difference?
Because the narrowing engine is just not that smart. You narrowed on data[0][0]—we don’t recognize that this could imply anything about data[0]. You will only see refinements on data[0][0] and its sub-properties. Like I said, this will probably be fixed by #38839, making our behavior here consistently unsound. But you’ll always be able to find ways like this to defeat control flow narrowing.
It seems to me that the type of awaited could be expressed more specifically than unknown as string | (T extends Promise<infer U> ? U : T)
because if T happens to be Promise-like, we have no idea what type it will resolve to.
While we don't know what type it will resolve to, that doesn't mean we're completely unable to reason about it on a generic level and convey that information back to the calling context.
I'm trying to think of cases where an unsound lowering to string | T would be more useful than inferring string | (T extends Promise<infer U> ? U : T).
Thinking about it this way both #45686 / #46534 as well as the generic await example remind me of the way unions with undefined or null are treated. We have a strictNullChecks flag to let the user decide whether he wants the "utility" of just ignoring undefined and null or the "correctness" of always having full handling of possible undefined value cases enforced.
Should this issue perhaps be resolved in a similar way?
Intended behavior in non strict mode: Make the unsound assumption, that a generic parameter T does not conform against a common parent type shared with other more explicitly declared types in the union.
Strict behavior guarded behind a new strict checking flag: Account for the fact that generic parameters can also satisfy the same typeguards applying to other union members and enforce explicit handling of the case that they do.
On a more general note:
so we have to choose between being correct and being useful, and we choose utility
Andrew Branch (@andrewbranch) Does the TypeScript project have some more general policy on when to choose correctness vs utility documented somewhere, that I could read up on?
When running the TypeScript compiler with the strict flag being set, I would expect the compiler to not make any (intentional) unsound assumptions about the types in my code. Making unsound assumptions for increased usability would be a feature, I'd expect to be limited to non fully strict operating modes. Is that not the case / goal?
Activity
andrewbranch commentedon Sep 2, 2021
I think my eyes are just tripping over all the square brackets, but can you explain why you expect your expected behavior? Here’s what I see:
data[0][0]isK[] | V. No argument here.K[]. This is unsound because it ignores the possibility thatVitself is instantiated with an array type. Every other type that follows from this flawed assumption is technically wrong, the first of which isresultasKin both functions. It should arguably beunknown, but that would be a bummer.working, I think you just defeat the narrowing logic by grabbing a reference to a higher-up property than the one that was narrowed. (This will possibly be “fixed” by Type guard by deep property #38839.)So, I think I agree with your conclusion (on technical grounds, but this is a known/desired unsoundness that we really don't want to get rid of without negated types), but I wonder if you used the same logic to get there, because the easier way to demonstrate my conclusion is just by saying that
resultshould beunknownin both functions.benlesh commentedon Sep 2, 2021
This is a minimal reproduction of a situation a co-worker hit. Basically this is some deserialized nonsense from a server and he wanted to add some type safety around it. (I actually pointed out to him that one type is a superset of the other) Either way, it seems like a strange technical error. I just wanted to bring it to your attention. I'm not sure how else this could manifest or even if it does. 🤷♂️
Specifically, I don't necessarily agree with the logic above, It's just that I saw the strange type result and I thought I would report it.
andrewbranch commentedon Sep 2, 2021
Well, ignore my explanation for the moment. Can you explain why you expected your expected behavior? The flaw I pointed out in my explanation is fairly subtle, and if I take off my pedantic hat, the actual behavior looks correct to me.
benlesh commentedon Sep 3, 2021
Honestly, the structure/logic of the code doesn't really matter to me, that's just copy pasta from the code I was helping look at. It's the second part, and how it's effected by the
Array.isArraycheck.Vgeneric won't be an array. So theArray.isArraycheck can't possibly narrow anything. If V is a known type or extends some non-Array, then it can absolutely narrow it down, but there's no constraints in this code.V[] | [K[], V[]], Then I'd thinkdata[0][0]should always beV | K[]. Why would pullingdata[0]into it's own variable make a difference?Here's a more specific example
Put another way: This is only a problem generics, because generics could be arrays -- or anything else -- in this case. If you add hard types, it doesn't have any issues, as the
Array.isArrayactually does make a bit of difference, as you can see in this playgroundbenlesh commentedon Sep 3, 2021
Here's another way to put it that shows how this is broken
andrewbranch commentedon Sep 3, 2021
Ok right, we’re on the same page then. Because we don’t have negated types, there are all kinds of places we make unsound assumptions about what a type parameter is not. Consider an analogous example:
Here we assume that
awaitcannot have an effect on the typeTbecauseTdoes not look like a Promise, so theawaitshould just unwrap thePromise<string>or no-op on theT. ButTis absolutely allowed to be instantiated with something Promise-like, as long as that Promise-like type also has anid: stringproperty. BecauseTmight be Promise-like, the only sound type forawaitedisunknown, because ifThappens to be Promise-like, we have no idea what type it will resolve to. The only way to square this circle is using a negated type as a constraint forTas a way to say “has anidand does not have a callablethen.” But we have no way to express that, so we have to choose between being correct and being useful, and we choose utility.The array case is the same. It’s often useful, though incorrect, to assume that
Vis not an array. If you know that it really might be in practice, you need to come up with some way to defeat the narrowing. One way might be to constrain it to something likestring | number | boolean | object | unknown[]to make the compiler deal with the fact that it really might be array-like.Because the narrowing engine is just not that smart. You narrowed on
data[0][0]—we don’t recognize that this could imply anything aboutdata[0]. You will only see refinements ondata[0][0]and its sub-properties. Like I said, this will probably be fixed by #38839, making our behavior here consistently unsound. But you’ll always be able to find ways like this to defeat control flow narrowing.typescript-bot commentedon Sep 7, 2021
This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.
timbodeit commentedon Nov 5, 2021
It seems to me that the type of
awaitedcould be expressed more specifically than unknown asstring | (T extends Promise<infer U> ? U : T)While we don't know what type it will resolve to, that doesn't mean we're completely unable to reason about it on a generic level and convey that information back to the calling context.
Playground example
I'm trying to think of cases where an unsound lowering to
string | Twould be more useful than inferringstring | (T extends Promise<infer U> ? U : T).Thinking about it this way both #45686 / #46534 as well as the generic await example remind me of the way unions with undefined or null are treated. We have a
strictNullChecksflag to let the user decide whether he wants the "utility" of just ignoringundefinedandnullor the "correctness" of always having full handling of possibleundefinedvalue cases enforced.Should this issue perhaps be resolved in a similar way?
Tdoes not conform against a common parent type shared with other more explicitly declared types in the union.On a more general note:
Andrew Branch (@andrewbranch) Does the TypeScript project have some more general policy on when to choose correctness vs utility documented somewhere, that I could read up on?
When running the TypeScript compiler with the
strictflag being set, I would expect the compiler to not make any (intentional) unsound assumptions about the types in my code. Making unsound assumptions for increased usability would be a feature, I'd expect to be limited to non fully strict operating modes. Is that not the case / goal?