Typescript: Conditional type coerces Enum to intersection of constituents

Created on 5 Jun 2019  Â·  4Comments  Â·  Source: microsoft/TypeScript

TypeScript Version: 3.6.0-dev.20190604 (typescript@next at the time of writing this)

Search Terms:
conditional type enum intersection coercion error TS2345

Code

export enum E {
    A,
    B,
}

export interface CreatorWithInput<T> {
    (args: T): void;
}

export interface Creator {
    (): void
}

// Version A
// This works:
// export type PickCreator<T = undefined> = CreatorWithInput<T>;

// Version B
// But this does not (when T !extends undefined)
export type PickCreator<T> = T extends undefined
    ? Creator
    : CreatorWithInput<T>;

function A<T = undefined>() {
    const c = (t?: T) => { };
    return c as PickCreator<T>;
}

// Type of 'a'
// Version A: CreatorWithInput<E>
// Version B: CreatorWithInput<E.A> | CreatorWithInput<E.B>
const a = A<E>();

const b = A();

function Q(e: E) {

    a(e); // <--- Error when Version B is used (unexpected / incorrect). 

    // Constraints for the problem, unrelated to the error
    // a(); // Both should error (desired)
    b(); // Version A will error (undesired). Version B will not error (desired)
}

Expected behavior:
Version B should not error on a(e).

  • Reading the code backwards from a(e);, it seems very logical that this should be the correct behavior.
  • To make this opinion more concrete, I've included Version A, which basically shows the same "path" that Version B takes in the conditional type, except that this works as expected and does not produce the error.

Actual behavior:
Version B errors on a(e) with error:
error TS2345: Argument of type 'E' is not assignable to parameter of type 'E.A & E.B'.

Playground Link

Related Issues:
Not able to find any related to this specific issue.

Most helpful comment

To prevent the distributive behavior:

type PickCreator<T> = [T] extends [undefined]
    ? Creator
    : CreatorWithInput<T>;

As for why ((value: A) => void) | ((value: B) => void) is treated differently from (value: A | B) => void... well, it's the same reason Herbivore | Carnivore is different from Omnivore. 🦖

All 4 comments

E is exactly the type E.A | E.B, i.e. the union of its constituents. This is similar to how boolean is exactly the union true | false.

Because E is a union, the conditional type:

type PickCreator<T> = T extends undefined
    ? Creator
    : CreatorWithInput<T>;

...when instantiated with T = E, distributes to:

type PickCreatorE = CreatorWithInput<E.A> | CreatorWithInput<E.B>;

/* and therefore... */
type PickCreatorE = ((args: E.A) => void) | ((args: E.B) => void);  // ...essentially

If the only thing we know about a function is that it's of type PickCreatorE, we can only safely call it with a hypothetical argument of type E.A & E.B. So that's where the intersection is coming from. This is by design and is the exact mechanism that makes the ever-popular UnionToIntersection work:

// roughly:
// convert a union to a union of functions, then ask what type of argument we need in order
// to call the function-union (fun with contravariance!)
type UnionToIntersection<U> =
    (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;

To prevent the distributive behavior:

type PickCreator<T> = [T] extends [undefined]
    ? Creator
    : CreatorWithInput<T>;

As for why ((value: A) => void) | ((value: B) => void) is treated differently from (value: A | B) => void... well, it's the same reason Herbivore | Carnivore is different from Omnivore. 🦖

Thanks!

For anyone else who finds this, here's some documentation on this:
https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html
Search for "Distributive conditional types"

The reason @fatcerberus's solution works is because TypeScript will only distribute the type parameter if the checked type is "naked". So by making it not "naked", as shown above, this will prevent the conditional type from being a distributive conditional type. The relevant part of the docs:

Conditional types in which the checked type is a naked type parameter are called distributive conditional types. Distributive conditional types are automatically distributed over union types during instantiation.

because TypeScript will only distribute the type parameter if the checked type is "naked"

This the way it’s documented but the reason it works this way is actually a bit more subtle (and IMO kind of elegant): wrapping A | B in a tuple types converts a union of types into a single, indivisible type: “array of A or B” is different from “array of A or array of B” for the same reason as the herbivore/carnivore thing above (yay contravariance!)

So it’s not that TS won’t distribute over an “embellished” type parameter but simply that it can’t—as there’s only one type. :smiley:

Was this page helpful?
0 / 5 - 0 ratings

Related issues

fwanicka picture fwanicka  Â·  3Comments

zhuravlikjb picture zhuravlikjb  Â·  3Comments

manekinekko picture manekinekko  Â·  3Comments

kyasbal-1994 picture kyasbal-1994  Â·  3Comments

uber5001 picture uber5001  Â·  3Comments