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 []