Minimal repro:
type Oops<T> =
[T] extends [infer U]
? (U extends unknown ? [U, T] : never)
: never;
type X = Oops<"hello" | "world">;
Actual
type X = ["hello", "hello"] | ["world", "world"]
Expected
type X = ["hello", "hello" | "world"] | ["world", "hello" | "world"]
Use-case:
I want to define a type Exclusify that defines mutually exclusive properties, much in the same way that object literal type normalization does. So
type Foo = Exclusify<"hello" | "world", number>;
should be equivalent to
type Foo =
| { hello: number, world: undefined }
| { hello: undefined, world: number };
I can do this by defining Exclusify with a generic default to avoid distributing on Keys.
type Exclusify<Keys extends keyof any, V, SingleKey = Keys> =
// Distribute on SingleKey
SingleKey extends unknown
? { [K in Keys]: K extends SingleKey ? V : undefined }
: never;
type Foo = Exclusify<"hello" | "world", string>
This works! But I'd rather not use a generic with a default. Instead, I figured I could add a conditional type.
type Exclusify<Keys extends keyof any, V> =
// Introduce SingleKey
[Keys] extends [infer SingleKey]
// // Distribute on SingleKey
? SingleKey extends unknown
? { [K in Keys]: K extends SingleKey ? V : undefined }
: never
: never
type Foo = Exclusify<"hello" | "world", string>
This, uh, doesn't work? It gives me
type Foo =
| { hello: string; }
| { world: string; }
Re-introducing SingleKey as a defaulted type parameter and replacing the infer SingleKey with unknown fixes it.
type Exclusify<Keys extends keyof any, V, SingleKey = Keys> =
[Keys] extends [unknown]
// // Distribute on SingleKey
? SingleKey extends unknown
? { [K in Keys]: K extends SingleKey ? V : undefined }
: never
: never
type Foo = Exclusify<"hello" | "world", string>
It doesn't make sense to me why [Keys] gets distributed in the presence of an infer.
This happens because conditions of the form [T] extends [T] ? … are eagerly reduced, and this happens after inference. So Oops will get simplified to (T extends unknown ? [T, T] : never) before it is ever instantiated. The unfortunate business is that the semantics of a conditional type are not preserved by inlining or type substitution. The non-distributive T now becomes distributive by the instantiation of U to T.
The last type:
type Exclusify<Keys extends keyof any, V, SingleKey = Keys> =
[Keys] extends [unknown]
// // Distribute on SingleKey
? SingleKey extends unknown
? { [K in Keys]: K extends SingleKey ? V : undefined }
: never
: never
gets eagerly reduced to:
type Exclusify<Keys extends keyof any, V, SingleKey = Keys> =
SingleKey extends unknown
? { [K in Keys]: K extends SingleKey ? V : undefined }
: never
so really the only thing that matters here is having two parameters. I usually end writing types like
type OopsWorker<U, T> = U extends unknown ? [U, T] : never
type Oops<T> = OopsWorker<T,T>;
Is there a better way here? It seems like candidate options might be less eager conditional type resolution, or some more complex form of instantiation that somehow knows to stage instantiations:
type Oops2<T> =
// dist mapper T1 -> T
// non-dist mapper T2 -> T
// when instantiating with an outer mapper T -> "hello" | "world",
// instantiate first using x => outer(non-dist(x))
// then apply the dist mapper using existing mechanism
T1 extends unknown ? [T1, T2] : never
In other words, the mapper built by doing inference on a non-distributive conditional type should be applied before doing the instantiation + distribute for distributive conditional types.
I think the current behavior is reasonable so I'm going to call it working as intended. As we've discussed before, for scenarios like this we could consider a let T = ... in ... type construct to introduce local aliases.
This definitely used to work differently (e.g., in TS3.3), so while it might be reasonable, it seems more like an unintended side effect instead of "working as intended".
Note: I'm only salty because various answers I posted to SO between v2.8 and v3.3 have broken because of this. (I'm trying to come up with a joke about breaking FWC where F stands for "Fake", but it's not coalescing for me.)
Here is an example of divergent type resolution (see this post for more details):
type NotAUnion<T> = [T] extends [infer U] ?
U extends any ? [T] extends [U] ? T : never : never : never;
For TS 3.3 the result is:
type T1 = NotAUnion<0 | 1> // never
In TS 3.5.1, we get:
type T1 = NotAUnion<0 | 1> // 0 | 1
@ahejlsberg I would have assumed, former behavior is reasonable, as it works consistently for me in terms of distributive conditional types. There seemed to be an implicit change in TS 3.4 or TS 3.5.
Edit: I tried out some versions locally. The last version, that works like in first example is [email protected]. [email protected] changes the behavior.
Most helpful comment
This happens because conditions of the form
[T] extends [T] ? …are eagerly reduced, and this happens after inference. SoOopswill get simplified to(T extends unknown ? [T, T] : never)before it is ever instantiated. The unfortunate business is that the semantics of a conditional type are not preserved by inlining or type substitution. The non-distributiveTnow becomes distributive by the instantiation ofUtoT.The last type:
gets eagerly reduced to:
so really the only thing that matters here is having two parameters. I usually end writing types like
Is there a better way here? It seems like candidate options might be less eager conditional type resolution, or some more complex form of instantiation that somehow knows to stage instantiations:
In other words, the mapper built by doing inference on a non-distributive conditional type should be applied before doing the instantiation + distribute for distributive conditional types.