ELI5 Distributive Conditional Types in TypeScript
Update: video with the same content
I've been aware of distributive conditional types in TypeScript for a while, but recently, finally, I figured out how they work. As much as I love TS, the docs often leave something to be desired, and that's definitely the case for distributive conditional types.
So I have two goals with this post:
- Write the ELI5 docs that I needed a few weeks ago
- Solicit corrections where I'm wrong
Behold, a simple union:
type ASimpleUnion = string | number;
Let's start with something obvious: there's no difference between the type alias of a union and the "union literal" (is that a term?) when using them as a generic argument:
type A1 = Array<ASimpleUnion>
// same as
type A2 = Array<string | number>
Both versions are the same shape: an array that can include strings and numbers.
So, what if, instead of transforming ASimpleUnion
into an array of strings and numbers (as we do above with A2
), we want to transform it into a union of either an array of strings or an array of numbers, like A3
below?
type A3 = Array<string> | Array<number>;
To create A3
, we need to distribute the string | number
union. Essentially, we're "iterating" over the members of the union, transforming each member into a new type, and then "union-ing" them back together.
This is where distributive conditional types come into play. To trigger the distribution of a union, we can use the one-two-punch of a generic argument and a conditional type. Both of those mechanics are important. Here's an example that lays it out.
How Generics & Conditional Types Work Together
(Bear with my contrived examples: a practical one is coming up next. The super-basic examples help me focus on behaviour of the TypeScript mechanics here.)
Let's start with a union of a few string constants and undefined
:
type AnotherUnion = "one" | "two" | undefined | "three";
As we've seen earlier, if we use AnotherUnion
as a generic argument, it's treated as a single "unit", literally an alias:
type T = Array<AnotherUnion>;
// Array<"one" | "two" | undefined | "three">
Then, if we use AnotherUnion
in the condition of a conditional type, it's also treated as a type alias:
type T = AnotherUnion extends string ? AnotherUnion : never;
// never;
Here, AnotherUnion
doesn't extend string
(because it includes undefined
), so T
results in never
.
But when we use these two TS mechanics together, we can distribute the union:
type OnlyStrings<T> = T extends string ? T : never;
type T = OnlyStrings<AnotherUnion>;
// "one" | "two" | "three"
We combine the use of a generic argument with the conditional type, and the result is that T
is a union of only the strings in AnotherUnion
. Each member of AnotherUnion
was individually tested in the T extends string
condition, and the resulting type is the union of each individual conditional check. In this case, we've essentially filtered our original union.
Non-Filtering Examples
Using a conditional type for filtering feels pretty natural. But that's not always what we want to do. Remember our earlier question:
// how do we convert this:
type ASimpleUnion = string | number;
// to this:
type A3 = Array<string> | Array<number>;
Well, we want to distribute our union, so let's use both a generic argument and a conditional type:
type ToArray<T> = T extends T ? Array<T> : never;
type A4 = ToArray<ASimpleUnion>;
// Array<string> | Array<number>
Okay ... but what's up with T extends T
? Well, the actual condition doesn't really matter here, it's just the mechanism TypeScript uses (along with the generic argument) to trigger the union distribution. If we want to avoid filtering out any of the members of our union, we need to use an always-true condition. T extends T
is a popular one, since every element of T
is (obviously) in the set of all values in T
.
(T extends any
is also popular, but I tend to avoid any
, and I think T extends T
gives me just enough pause to recognize that this isn't a "normal" conditional.)
How to avoid distributing a union
What if you need to use a union with a generic argument and a conditional type, but you don't want to trigger distribution? The right mechanic to use is wrapping both sides of your conditional expression (both T
in our case) in a tuple. We can tweak the last example to see this in action:
type ToArray<T> = [T] extends [T] ? Array<T> : never;
type A4 = ToArray<ASimpleUnion>;
// Array<string | number>
Again, a contrived example, but it shows the mechanic in action.
A Practical Example of Distributing a Union
Here we have various form element response shapes:
type CheckboxResponse = {
id: string;
type: "checkbox",
value: boolean,
};
type TextResponse = {
id: string;
type: "text",
value: string,
}
type FormResponse = CheckboxResponse | TextResponse;
Notice specifically that we have a strong correlation between the type
and value
fields, which is great when narrowing FormResponse
. But now let's omit the id
field:
type RenderedFormResponse = Omit<FormResponse, 'id'>;
// {
// type: "checkbox" | "text";
// value: string | boolean;
// }
Before learning all this, I would have expected RenderedFormResponse
to be the same union shape, just without the id
fields in each member of the union. However, if we take a look at the implementation of Omit
below, we can see that it uses a generic argument, but no condition, so there's no distribution:
type Omit<T, K extends string | number | symbol> = {
[P in Exclude<keyof T, K>]: T[P];
}
It's also a mapped type, and the effect of mapping over a non-distributed union is that it's "flattened." We've lost the link between the type
field and the value
field.
We can solve this pretty simply: wrap Omit
in a type that includes a condition, to trigger the distribution:
type DistributiveOmit<T, K extends string> = T extends T ? Omit<T, K> : never;
type RenderedResponse = DistributiveOmit<FormResponse, 'id'>;
// Omit<CheckboxFormResponse, "id"> | Omit<TextFormResponse, "id">
Wrap Up
If you browse through the types in popular libraries, you'll more than likely see distributivity in practice.
For example, in the React type definitions, you can see a specific case: a type that omits the ref
prop from a component prop types.
If you have other practical examples if distributive conditional types at work, I'd love to see them! Also, if I got something wrong here, let me know!