Advanced TypeScript: Distributive Conditional Types
Welcome to the Distributive Conditional Types lesson!
This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!
TypeScript comes with a utility type called
Excludethat lets us remove alternatives from a union. We can think of it as "subtracting" two unions. If we doExclude<A|B|C|D, A|B>, we get back the typeC | D. We've excluded theAandBalternatives from the union.>
type BNS = boolean | number | string;function returnNumber(): BNS {return 1;}const n: number = returnNumber();n;Result:
type error: Type 'BNS' is not assignable to type 'number'. Type 'string' is not assignable to type 'number'.
>
type BNS = boolean | number | string;function returnNumber(): Exclude<BNS, boolean | string> {return 1;}const n: number = returnNumber();n;Result:
1
The
Excludetype seems like it must be doing something weird and complicated. It's definitely weird. But, surprisingly, its definition is quite short!Here's the entire definition of
Exclude. At first glance, it will probably seem like we've shown you the wrong type definition. We'll dissect it below to see how it works.(We found this in the file "node_modules/typescript/lib/lib.es5.d.ts". But we also changed the type parameter names to make them more clear. The original type parameters were named
TandU.)>
type Exclude<Original, ToExclude> =Original extends ToExclude ? never : Original;This type relies on two subtle features of TypeScript. The first feature is unions with
never. In a past lesson, we saw that TypeScript discards anynevertypes inside of unions:number | neveris justnumber. Any type likeT | neveris always justT, regardless of what typeTis.The second feature that
Excluderelies on is new to us. We'll look at a contrived example first, see why it works, and then return to the actualExcludetype at the end of this lesson.In the examples below,
StringToNullbuilds a new union out of the old one. It replaces eachstringtype in the union withnull. For example,StringToNull<string | number>gives us the typenull | number.>
type StringToNull<T> =T extends string ? null : T;- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
function returnsNullOrNumber(): null | number {return 1;}const n: StringToNull<string | number> = returnsNullOrNumber();n;Result:
1
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
function returnsNullOrNumber(): null | number {return null;}const n: StringToNull<string | number> = returnsNullOrNumber();n;Result:
null
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
function returnsNullOrNumber(): null | string {return null;}const n: StringToNull<string | number> = returnsNullOrNumber();n;Result:
type error: Type 'string | null' is not assignable to type 'number | null'. Type 'string' is not assignable to type 'number'.
In that last example, our function returns a
null | string, but ournvariable has a type ofStringToNull<string | number>, which reduces tonull | number.Now, how does the
StringToNulltype actually work? It didn't mention anything about different union alternatives, so how is it replacing some but not all of the alternatives?The answer is that the conditional type in
StringToNullis checked separately for each union alternative. Here's the type again:>
type StringToNull<T> =T extends string ? null : T;Result:
When we do
StringToNull<string | number>, the compiler works through it step by step like this:- The first union alternative is
string. Isstring extends stringtrue? Yes, so we replacestringwithnull. - The second union alternative is
number. Isnumber extends stringtrue? No, so we leavenumberalone. - We've now applied the conditional type to both union alternatives.
It gave us the new types
nullandnumber. Combine them back into a new union, givingnull | number. That's the type ofStringToNull<string | number>.
- The first union alternative is
This is one of the weirdest parts of the TypeScript type system. Nothing in the type definition itself says that this will happen. We just have to know that this is how conditional types work when combined with union types.
Now we can return to the
Excludetype. For the rest of this lesson, we'll use our own version,OurExclude. We name itOurExcludeonly to avoid conflicting with the built-in version. The two types work identically and have identical definitions, other than the type parameter names that we changed for clarity.>
type OurExclude<Original, ToExclude> =Original extends ToExclude ? never : Original;type BNS = boolean | number | string;- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
function returnNumber(): OurExclude<BNS, boolean | string> {return 1;}const n: number = returnNumber();n;Result:
1
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
function returnNumber(): OurExclude<BNS, boolean> {return 1;}const n: number = returnNumber();n;Result:
type error: Type 'string | number' is not assignable to type 'number'. Type 'string' is not assignable to type 'number'.
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
function returnBooleanOrString(): OurExclude<BNS, number> {return 'ok';}const n: number = returnBooleanOrString();n;Result:
type error: Type 'string | boolean' is not assignable to type 'number'. Type 'string' is not assignable to type 'number'.
Here's a code problem:
The
RemoveStringsFromUniontype below should remove thestringtype from any union. For example,RemoveStringsFromUnion<string | number>is justnumber.Finish the type's implementation by using a conditional type. Because of TypeScript's distributive conditional types feature, your conditional type will be applied independently to each of the union alternatives.
Remember that
T | neveris alwaysT. You can use that to "remove" union alternatives by replacing them withnever.type RemoveStringsFromUnion<T> = T extends string ? never : T;const n1: RemoveStringsFromUnion<string | number> = 11;const n2: number = n1;const b1: RemoveStringsFromUnion<string | boolean> = true;const b2: boolean = b1;[n2, b2];- Goal:
[11, true]
- Yours:
[11, true]
You're now well into the advanced features of TypeScript. It's no surprise that we're discussing one of the weirdest parts of the language. Depending on what kind of work you do, you could go years without ever needing to filter union types in this way!
However, there are many real-world situations where these distributive conditional types are required to model types accurately. In the long term, you'll likely encounter this language feature one way or another, even if it's only when reading someone else's type definitions.
A final note about terminology. In the example below, the
PropertyTypes1andPropertyTypes2types are the same. Both of them arestring | number.>
type User = {name: string, age: number};type PropertyTypes1 = User['name' | 'age'];type PropertyTypes2 = User['name'] | User['age'];Result:
In
PropertyTypes1, TypeScript "distributes"Useracross the two property names. Internally, the TypeScript compiler turns it into something more likePropertyTypes2: it appliesUser[...]to both of the property names, givingUser['name'] | User['age'].This is the same idea that we saw in an earlier lesson on type algebra. There, we saw that
&and|distribute:A & (B | C)is the same as(A & B) | (A & C).The feature that we saw in this lesson is called "distributive conditional types". TypeScript distributes our conditional type across the different union alternatives. It breaks the union into its separate alternatives, applies the conditional type to each of them, and then builds a new union from the results.
Remembering the term "distributive conditional types" isn't very important. You can use this feature correctly without remembering what it's called. But if you see someone using that term, now you'll know what it's referring to!