Description
Bug Report
ConditionalRoot.isDistributive
is a flag that indicates that a conditional "distributes" over union. Its set to true when the checkType
is a TypeParameter
. 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
type KeysExtendedBy<T,U> = keyof {[K in keyof T as U extends T[K] ? K :never] : T[K]};
interface M {
a: boolean;
b: number;
}
function f(x : KeysExtendedBy<M,number>) {
return x;
}
f("a"); // <- f should only accept "b" as a parameter.
Here, getIndexOfMappedType
optimizes KeysExtendedBy<T,U>
to U 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:
type KeysExtendedBy<T,U> = keyof {[K in keyof T as T[K] extends unknown ? U extends T[K] ? K : never : never] : T[K]};
Now the top level conditional is not isDistributive
, and we don't call getIndexOfMappedType
.
For the second case:
type Bug<T, U> = U extends "a" ? T[U & keyof T] : never;
interface M {
a: boolean;
b: number;
}
function f(x : Bug<M, "a"|"b">) {
return x;
}
f(false);
Clearly, "a"|"b"
does not extend "a"
, so f
's parameter type should be never
. But instantiateConditionalType
notes that root.isDistributive
is set (since U
is a TypeParameter
), and that checkType
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 to M["a"]
which is boolean
.
I think these bugs show pretty conclusively that setting isDistributive
whenever the checkType
is a TypeParameter
is broken. Perhaps only doing it when the checkType is a MappedType
's type parameter (ie the K
above) would be safe; but even then I think it often relies on instantiateConditionalType
to fix it. Consider eg:
type KeysExtendingLiteral<T> = keyof {
[K in keyof T as K extends "b" ? K : never] : T[K];
}
interface M {
a: boolean;
b: number;
}
function f(x: KeysExtendingLiteral<M>) {
return x;
}
f("b");
KeysExtendingLiteral
gets converted to keyof 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 - because instantiateConditionalType
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
- This is the behavior in every version I tried, and I reviewed the FAQ for entries about it.
⏯ Playground Links
💻 Code
type KeysExtendedBy<T,U> = keyof {[K in keyof T as U extends T[K] ? K : never] : T[K]};
interface M {
a: boolean;
b: number;
}
function f(x : KeysExtendedBy<M,number>) {
return x;
}
f("a");
🙁 Actual behavior
The call to f("a") is deemed correct
🙂 Expected behavior
It should fail because number
does not extend M["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).