Why does TypeScript incorrectly compute my conditional type when given a union type?

Viewed 184

I learned about conditional types in TypeScript by reading the first half of the blog post and the first half of the documentation and I think I've found a longstanding bug in TypeScript.

This code, for example, produces the type ({ value: string } | { value: number })[], when I would expect it to produce the type ({ value: string | number })[]

// Given an array, make a new array that boxes up string or
// number elements into a new object with a 'value' property
declare function boxup<T>(arr: T[]): Array<T extends string | number ? { value: T } : never>;
let myArray = ["hello", 42];
// 'result' has unexpected type as described above
let result = boxup(myArray);

Why is TypeScript doing the wrong thing here?

1 Answers

There are a few ways to imagine how boxup might work.

Interpretation 1: Throw on nonmatching elements

The first would be something like this, where the entire array maps to an entirely new array with the same shape as the original, and the original array can only contain strings or numbers.

function boxup<T>(arr: T[]) {
    const arr[] = [];
    for (const el of arr) {
        if (typeof el === "string" || typeof el === "number") {
            arr.push({ value: el });
        } else {
            throw new Error("Wrong!")
        }
    }
    return arr;
}

If this is how boxup works, then the correct results of different calls are:

  • boxup(["hello", 42]) should be ({ value: string } | { value: number })[] (a heterogenous array)
  • boxup([1, 1]) should be ({ value: number })[] (the output array has the same types as the input array)
  • boxup([1, true]) should be never (or the call should be flagged as an error) - the function throws in this case
  • boxup([true]) should be never (or the call should be flagged as an error) - the function throws in this case

Interpretation 2: Pluck an arbitrary exemplar element of those which match

The second would be something like this, where one or more elements from the original array is used to produce a new homogenous array:

function boxup<T>(arr: T[]) {
    if (typeof el === "string" || typeof el === "number") {
        return [{ value: el }, { value: el }];
    }
    return [];
}

If this is how boxup works, then the correct results of different calls are:

  • boxup(["hello", 42]) should be ({ value: string })[] | ({ value: number })[] (one of two different homogenous arrays)
  • boxup([1, 1]) should be ({ value: number })[] (the output array has corresponding types from the input array)
  • boxup([1, true]) should be ({ value: number })[] - the boolean is filtered out
  • boxup([true]) should be never[], which is the type of an empty array

Interpretation 3: Filter on nonmatching elements

The third would be something like this, where the elements are filtered by their type and we return that:

function boxup<T>(arr: T[]) {
    const arr[] = [];
    for (const el of arr) {
        if (typeof el === "string" || typeof el === "number") {
            arr.push({ value: el });
        }
    }
    return arr;
}

If this is how boxup works, then the correct results of different calls are:

  • boxup(["hello", 42]) should be ({ value: string })[] | ({ value: number })[] (one of two different homogenous arrays)
  • boxup([1, 1]) should be ({ value: number })[] (the output array has corresponding types from the input array)
  • boxup([1, true]) should be ({ value: number })[] - the boolean is filtered out
  • boxup([true]) should be never[], which is the type of an empty array

So...

Which one did you mean when you wrote that function signature? TypeScript has to pick an interpretation of what you meant here.

Let's wrap up the output into a convenient type alias for testing purposes

type BoxOutputX<T> = Array<T extends string | number ? { value: T } : never>;

Testing this based on our possible input types

  • BoxOutputX<string | number> is ({ value: string } | { value: number})[]
  • BoxOutputX<number> is ({ value: number})[]
  • BoxOutputX<number | boolean> is ({ value: number})[]
  • BoxOutputX<boolean> is never[]

This is interpretation 2 from the above list. But what if we wanted other behavior?

If you want interpretation 1, you should write this instead:

type BoxOutput1<T extends string | number> = Array<[T] extends [string | number] ? { value: T } : never>;

With this definition, the results are:

  • BoxOutput1<string | number> is the expected { value: string | number }[]
  • BoxOutput1<number> is the expected { value: number }[]
  • BoxOutput1<number | boolean> is a type error
  • BoxOutput1<boolean> is a type error

If you want interpretation 3, you should write this instead:

type BoxOutput3<T> = Array<{ value: T extends string | number ? T : never }>;

With this definition, the results are:

  • BoxOutput3<string | number> is the expected type { value: string | number }[]
  • BoxOutput3<number> is the expected type { value: number }[]
  • BoxOutput3<number | boolean> is the expected type { value: number }[]
  • BoxOutput3<boolean> is the expected type never[]

In conclusion, depending on what the actual behavior of your code is, you will need to write different types to accurately describe that behavior. TypeScript will consistently interpret these different types to mean different things; this consistent interpretation based on the different code you write is how TypeScript is able to let you describe the many different ways that boxup might work.

Wait but why?

Why did all that happen?

In TypeScript, a conditional type is distributive if it directly operates over a type parameter:

// Distributive - the thing directly to the left of 'extends' is exactly a type parameter (T)
type Foo<T> = T extends Bar ? T : string;

// Not distributive
type Baz<T> = { name: T } extends SomeType ? { lastName: T } : string;

When a type is distributive, each constituent of any union type is individually evaluated, and the result is combined into a new union:

// Distributive
type NumbersOnly<T> = T extends number ? T : never;
// Distribution applied: X is 1 | 2
type X = NumbersOnly<1 | 2 | "foo" | "bar">
// Same as if you had written
type X = NumbersOnly<1> | NumbersOnly<2> | NumbersOnly<"foo"> | NumbersOnly<"bar">

Usually - probably 80% of the time - distributivity is what you want. The other 20% of the time, you can make your type nondistributive by violating the "directly operates over a type parameter" rule by any means you like. Conventionally, this is done by wrapping the type parameter and its check type in a 1-element tuple type

// Distributive
type Foo1<T> = T extends Bar ? T : string;

// Nondistributive
type Foo2<T> = [T] extends [Bar] ? T : string;

The exact implications of all this are beyond the scope of a StackOverflow question - you should read the second half of the docs and release notes - but the two simple rules to keep in mind are:

  • Conditional types which test a bare type parameter are distributive
  • If you don't want distributivity, enclose the types before and after the extends in []
Related