Typescript: `T` is distributed in nested conditional `[T] extends [infer U] ? U extends unknown ? ...`

Created on 16 Oct 2019  ·  4Comments  ·  Source: microsoft/TypeScript

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.

Working as Intended

Most helpful comment

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.

All 4 comments

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.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

seanzer picture seanzer  ·  3Comments

bgrieder picture bgrieder  ·  3Comments

blendsdk picture blendsdk  ·  3Comments

fwanicka picture fwanicka  ·  3Comments

siddjain picture siddjain  ·  3Comments