Skip to content

ConditionalRoot.isDistributive is buggy #44019

Closed
@markw65

Description

@markw65

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 converts keyof { [ K in Keys as DistributiveConditional<K> ] : X } to DistributiveConditional<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 assumes getIndexOfMappedType did. This sometimes prevents bugs that getIndexOfMappedType 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

Example 1
Example 2
Example 3

💻 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).

Metadata

Metadata

Assignees

Labels

BugA bug in TypeScriptFix AvailableA PR has been opened for this issue

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions