Typescript: Conditional type distribution leads to undesirable behavior for booleans

Created on 15 Mar 2018  路  13Comments  路  Source: microsoft/TypeScript

type Foo<T> = T extends any ? T[] : never

type Bar = Foo<string | number | boolean>

Expected

Bar has type string[] | number[] | boolean[]

Actual

Bar has type string[] | number[] | true[] | false[]

Design Limitation

Most helpful comment

Given that boolean is simply an alias for true | false, it is hard to see by what rationale we'd not distribute over boolean but still distribute over 0 | 1 or "yes" | "no".

The issue here really isn't with boolean but rather with string and number. They represent infinite domains of values over which we cannot distribute operations, so by necessity they end up behaving differently. Even if we had "exclusion types" such as string except "a" | "b" they'd still behave differently.

I don't think there's any way we could magically get this "right" because the definition of right is all in the eye of the beholder. Sometimes you want union types to distribute, sometimes you don't. The important thing is that we be consistent in when we distribute and that we allow you to opt out:

  • We distribute over union types only when the leftmost type in a conditional type is a naked type parameter.
  • You can opt out by applying some covariant type constructor to the two types in the extends clause. For example [T] extends [undefined] ? X : Y checks whether T is exactly the type undefined, i.e. it would be true for undefined but not for string | undefined.

Here's one way to write the original example where you explicitly control which types are grouped:

type ArrayOrNever<T> = [T] extends [never] ? never : T[];

type Foo<T> =
    ArrayOrNever<Extract<T, string>> |
    ArrayOrNever<Extract<T, number>> |
    ArrayOrNever<Extract<T, boolean>> |
    ArrayOrNever<Exclude<T, string | number | boolean>>;

type T0 = Foo<string | number | boolean>;  // string[] | number[] | boolean[]
type T1 = Foo<'a' | 'b' | 0 | 1 | 2 | { a: string }>;  // ('a' | 'b')[] | (0 | 1 | 2)[] | { a: string }[]

Now, I'm still not sure what you'd actually do with this type.

Regarding @RyanCavanaugh's example above, it seems to me that you'd never want arrayification to be distributive, so:

type Arrayify<T> = [T] extends [Array<any>] ? T : T[];

All 13 comments

But Daniel, type Myboolean = true | false needs to behave the same way as boolean, since it's exactly the same type.

Like, you're suggesting that we need to start interpreting string | boolean as different than string | true | false, effectively. Your example isn't really unique to booleans.... If you had any aliased union and said Foo<string | Alias> you could argue that it's unexpected that alias got flattened into the top level union and mapped over.

Prior to distributive unions there wasn't really a place where a flattened union and an unflattened union would be perceived to behave differently, were there?

Hey, I get why it happens, but you clearly see why most users wouldn't want this, right?

Yeah, but I'm struggling to come to terms with the implications, since without just treating boolean as not-a-union (which may have some undesired effects), I don't see how you'd distinguish the intent of

type Foo<T> = T extends any ? T[] : never

type FooAndFalse<T> = Foo<T | false>

type Bar = FooAndFalse<string | number | true>

from the intent of your example.

@DanielRosenwasser means this but this is very hard work.

type BT<T> = T extends boolean ? T[] : never
type BL<T> = T extends true | false ? T[] : never
type A = BT<boolean> // boolean[]
type B = BL<boolean> // true[] | false[]

This is what I use as a work around:

type Atomic<T> = {__v: T} & {__dontDistribute};
type Foo<T> = T extends Atomic<infer U> ? ([U] extends [any] ? U[] : never) : (T extends any ? T[] : never);
type Bar = Foo<string | number | Atomic<boolean>>;

The main problem with this is that [U] extends [T] wont add U extends T constraints in the true branch.

I think we have a conflict between "Conditional types should always distribute over unions" and "Primitives (where possible) should behave the same as a union of all their possible values". e.g.:

type Arrayify<T> = T extends Array<any> ? T : T[];
// Error
const b1: Arrayify<boolean> = [true, false];
// OK
const s1: Arrayify<string> = ["a", "b"];

The fact that the unit type boolean has a finite number of values shouldn't lead to magically different behavior compared to its infinitely-domained counterparts. This is especially confusing because the user never wrote a union type in the above example.

I also don't like the fact that a conditional type does not behave the same as if you wrote its in-place substitution:

type Arrayify<T> = T extends Array<any> ? T : T[];
// Error
const u1: Arrayify<string | null> = ["a", null];
// OK
const u2: Arrayify<Array<string | null>> = ["a", null];
// OK
const u3: Array<string | null> = ["a", null];

I think so too. I think Conditional type shouldn't distribute normal types to literal types without matching literal types.

Another point:

type B<T> = T extends true ? T : never;
type N<T> = T extends 0 ? T : never;
type b = B<boolean>; // true
type n = N<number>; // never

boolean matches own literal types but number doesn't. T extends 0 ? T : T should return 0 | Rest<0> (it will be unified to number) if possible.

@RyanCavanaugh

Primitives (where possible) should behave the same as a union of all their possible values

Is this not already thrown out with narrowing? x === true ? x : x narrows in the else expression but x === 0 ? x : x does not. The same applies for string, and also mapped types such as {[K in string]: K}. (I appreciate you said _where possible_ so me picking out edge-cases isn't entirely fair).

Is this a reasonable summary of options?

  • _Distribute over unions but exclude special cases such as_ boolean.
    Downside being boolean is no longer equivalent to true | false for conditionals. There are other cases where you might want to prevent distribution for non-primitive such as your example where this wouldn't help.
  • _Provide some built-in type that prevents lifting._
    Lifting can be prevented currently using tuples but this might clash with actual uses of tuples. Doing this also loses constraints that should be constructed by the condition being satisfied.
  • _Don't distribute._
    Given that it's very useful and can't be encoded (I think) this seems a non-option.
  • _Distribute over all primitives._
    This would make conditional types depart from the behavior of narrowing (though I think narrowing could use conditional types). While consistent it doesn't help with the fact that users may want boolean to be atomic. Would also require some built-in notion of a complement type, at least of the primitives. I think there would also be some issues with the completeness of extends.
  • _Leave it as-is but teach people the work arounds._

Given that boolean is simply an alias for true | false, it is hard to see by what rationale we'd not distribute over boolean but still distribute over 0 | 1 or "yes" | "no".

The issue here really isn't with boolean but rather with string and number. They represent infinite domains of values over which we cannot distribute operations, so by necessity they end up behaving differently. Even if we had "exclusion types" such as string except "a" | "b" they'd still behave differently.

I don't think there's any way we could magically get this "right" because the definition of right is all in the eye of the beholder. Sometimes you want union types to distribute, sometimes you don't. The important thing is that we be consistent in when we distribute and that we allow you to opt out:

  • We distribute over union types only when the leftmost type in a conditional type is a naked type parameter.
  • You can opt out by applying some covariant type constructor to the two types in the extends clause. For example [T] extends [undefined] ? X : Y checks whether T is exactly the type undefined, i.e. it would be true for undefined but not for string | undefined.

Here's one way to write the original example where you explicitly control which types are grouped:

type ArrayOrNever<T> = [T] extends [never] ? never : T[];

type Foo<T> =
    ArrayOrNever<Extract<T, string>> |
    ArrayOrNever<Extract<T, number>> |
    ArrayOrNever<Extract<T, boolean>> |
    ArrayOrNever<Exclude<T, string | number | boolean>>;

type T0 = Foo<string | number | boolean>;  // string[] | number[] | boolean[]
type T1 = Foo<'a' | 'b' | 0 | 1 | 2 | { a: string }>;  // ('a' | 'b')[] | (0 | 1 | 2)[] | { a: string }[]

Now, I'm still not sure what you'd actually do with this type.

Regarding @RyanCavanaugh's example above, it seems to me that you'd never want arrayification to be distributive, so:

type Arrayify<T> = [T] extends [Array<any>] ? T : T[];

Please see #22630 for the example.
This current implementation makes booleans not assignable to booleans without a cast.

Automatically closing this issue for housekeeping purposes. The issue labels indicate that it is unactionable at the moment or has already been addressed.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

Antony-Jones picture Antony-Jones  路  3Comments

blendsdk picture blendsdk  路  3Comments

remojansen picture remojansen  路  3Comments

CyrusNajmabadi picture CyrusNajmabadi  路  3Comments

DanielRosenwasser picture DanielRosenwasser  路  3Comments