-
Notifications
You must be signed in to change notification settings - Fork 12.8k
ConditionalRoot.isDistributive is buggy #44019
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
Comments
@RyanCavanaugh You asked for clarification on #43920. My investigation led to a rabbit hole which ended up here. Maybe you could take a look at this? |
Another interesting example, this time because |
@ahejlsberg thoughts? |
It turns out you can't just kill
only works because This definition works either way though [edit: but only for 'keyof' types]:
|
Yeah, we're not quite doing the right thing here. A type BTW, the notion of distributive conditional types isn't "broken" per se, it's simply a behavior we've chosen because the alternative (always non-distributive) would require an explicit Also, I should mention that a quick workaround for the issue is to write a non-distributive type in the type KeysExtendedBy<T, U> = keyof { [K in keyof T as [U] extends [T[K]] ? K : never] : T[K] }; But we should fix this so the workaround isn't required. |
@ahejlsberg Thanks for the explanations. After investigating a few test failures due to killing off isDistributive, I was starting to think it must be intentional. But its still very surprising that the behavior of a conditional depends on whether the checkedType is a type-parameter or not. eg modifying my second example a bit:
Its pretty surprising (to me) that Finally wrt your workaround; that's fine now... but what happens when tsc v42.1 starts optimizing |
@markw65 We wouldn't. Any design looks bad if you start speculating about what would happen in the presence of bad decisions - the key is to not make bad decisions. |
So what constitutes a bad decision in this context? Optimizing Are you saying that the set of optimizations that typescript will perform is now fixed for all time? |
I don't understand what this sort of strawmanning is trying to get at. Of course we need to restrict ourselves to optimizations that don't change the semantics of the operation, just like any other program ever written. We can't "optimize" |
@RyanCavanaugh Sorry, I'm genuinely puzzled here. I assumed I've been going through my code looking for conditional types that are But I was really trying to point out what appears to be a flaw in the implementation. Distributive Conditional Types talks about naked type parameters, but tsc seems to implement it as naked after applying optimizations (since |
That's a good question. I think we're using the word "optimization" differently here. I was interpreting this to mean "performance enhancement", I think you mean something closer to what I call "resolution". You've seen the code too and I think the clearest (though by no means clear) explanation is that if the test type, without regard to the right operand of There's no semantic resolution of
Because it's not a trick. The right side of the type Id<T> = T;
// Still distributive, because Id<T> resolved to a type parameter T
type A<T> = Id<T> extends string ? "A" : "B"; declare class M<T> {
// Still distributive; resolves to a type parameter
f(): { a: T }["a"] extends string ? "A" : "B"
}
const s = new M<string | number>();
const a = s.f(); |
Thanks - that makes things a little clearer, although it makes the definition of a distributive conditional much harder to pin down. Before I saw your last comment, I was experimenting with changing the
Which seems to be enough to only allow a syntactic type param, rather than anything that resolves to a type param (its possible this is the wrong check; I was surprised to find that I had to check for SyntaxKind.TypeReference, rather than SyntaxKind.TypeParameter). Now The tests all pass with this... but from what you wrote above, you want it to treat anything that resolves to a type parameter as distributive. Is that really the case? |
Generally people like to point to "consistency" as a boon, and here (as is often the case) we have competing definitions of what consistent would mean:
In general the type system depends on semantics, not syntax, so enforcing a new syntactic rule is potentially confusing even if it seems nominally simplifying in some other way. I think there should be a strong argument for why Why would you want to change it? Hearing the motivation behind that would be instructive. |
I guess my reasoning is that it would match what the documentation says. I mean, sure, you can argue that I think it was a mistake to not have specific syntax for distributive conditionals (eg something like |
I don't necessarily disagree with anything there, but will point out that it's really rather difficult to write a type expression that resolves to exactly a type parameter (except via a trivial why-even-do-that epicycle like the one you wrote), so in practice this is very rarely encountered. |
Re the documentation, I think this is still broadly consistent. If you see us say in the docs, for example, "If In the few cases where we do make distinctions on syntax vs semantics (e.g. you cannot legally write |
From my point of view, being a TypeParameter isn't part of the type system. At least, not in the way that being an array type or being a union type is a part of the type system. In fact being a TypeParameter especially a naked TypeParameter really seems to be part of the syntax... but as currently implemented by tsc its not (quite). I guess I thought this was obviously the intent, but clearly reasonable people disagree - and absent a spec, it's hard to argue what the (rather vague) description in the documentation is supposed to mean. So I'll concede at this point. Thanks taking the time to explain all this. |
@RyanCavanaugh Sorry, I had one more thought. Using the resolved type to determine whether or not a conditional is distributive means that some conditionals will be distributive with some compiler options, but not with others. eg This definitely seems wrong to me... I mean, obviously, I also found, (rather surprisingly to me) that
Should accept the call to g, since x should be of type |
Bug Report
ConditionalRoot.isDistributive
is a flag that indicates that a conditional "distributes" over union. Its set to true when thecheckType
is aTypeParameter
. When its true, two main things happen:getIndexOfMappedType
convertskeyof { [ K in Keys as DistributiveConditional<K> ] : X }
toDistributiveConditional<Keys>
which is clearly unsound in many cases (this is the bug reported in Incorrect optimization for keyof Mapped type #43920).instantiateConditionalType
attempts to undo what it assumesgetIndexOfMappedType
did. This sometimes prevents bugs thatgetIndexOfMappedType
would otherwise have introduced, but for other cases can break the conditional.For the first case, here's a simplified version of the example from #43920
Here,
getIndexOfMappedType
optimizesKeysExtendedBy<T,U>
toU extends T[keyof T] ? keyof T : never
(which is clearly wrong). And in this case, instantiateConditionalType can't fix it, because it only tries to spread on the checkType.This can be fixed by adding a redundant guard:
Now the top level conditional is not
isDistributive
, and we don't callgetIndexOfMappedType
.For the second case:
Clearly,
"a"|"b"
does not extend"a"
, sof
's parameter type should benever
. ButinstantiateConditionalType
notes thatroot.isDistributive
is set (sinceU
is aTypeParameter
), and thatcheckType
is a union (the keys of M), and so it replaces the conditional with("a" extends "a" ? M["a"] : never) | ("b" extends "a" ? M["b"] : never)
which reduces toM["a"]
which isboolean
.I think these bugs show pretty conclusively that setting
isDistributive
whenever thecheckType
is aTypeParameter
is broken. Perhaps only doing it when the checkType is aMappedType
's type parameter (ie theK
above) would be safe; but even then I think it often relies oninstantiateConditionalType
to fix it. Consider eg:KeysExtendingLiteral
gets converted tokeyof T extends "b" ? "b" & keyof T : never
(you can see this by hovering over KeysExtendingLiteral in line 8 of playground example 3) - which again, is not the same thing. The original should evaluate to "b" whenever T has a "b" field, and never otherwise. The modified version evaluates to never unless T has a "b" field, and no other fields. However, in this case, we do get the correct results - becauseinstantiateConditionalType
converts it back to a union of individual tests - ie("a" extends "b" ? "b" & "a" : never)|("b" extends "b" ? "b" & "b" : never)
My proposal is to rip out the isDistributive code altogether. The delicate dance where we first transform to an incorrect conditional, hope nobody notices, and then transform it back again at the end seems horribly unsound.
If this is really important for type inference, maybe the solution is to only call getIndexOfMappedType when the checkType is the MappedType's type parameter, and then set another flag to indicate the transformation was done, and have instantiateConditionalType check that flag. I'm not 100% convinced that's sound, but it should be much better than the current situation.
🔎 Search Terms
isDistributive
I found #43920 (my own report) which turns out to be a special case of the problems with isDistributive, and #30152 which appears to be a different problem.
🕗 Version & Regression Information
Its present on master, and has been since at least 4.1.5
⏯ Playground Links
Example 1
Example 2
Example 3
💻 Code
🙁 Actual behavior
The call to f("a") is deemed correct
🙂 Expected behavior
It should fail because
number
does not extendM["a"]
which is boolean, so "a" should not be included in the keys of the mapped type (and note that it isn't included in the mapped type itself).The text was updated successfully, but these errors were encountered: