Execute Program

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 Exclude that lets us remove alternatives from a union. We can think of it as "subtracting" two unions. If we do Exclude<A|B|C|D, A|B>, we get back the type C | D. We've excluded the A and B alternatives 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'.Pass Icon
  • >
    type BNS = boolean | number | string;

    function returnNumber(): Exclude<BNS, boolean | string> {
    return 1;
    }

    const n: number = returnNumber();
    n;
    Result:
    1Pass Icon
  • The Exclude type 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 T and U.)

  • >
    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 any never types inside of unions: number | never is just number. Any type like T | never is always just T, regardless of what type T is.

  • The second feature that Exclude relies on is new to us. We'll look at a contrived example first, see why it works, and then return to the actual Exclude type at the end of this lesson.

  • In the examples below, StringToNull builds a new union out of the old one. It replaces each string type in the union with null. For example, StringToNull<string | number> gives us the type null | 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:
    1Pass Icon
  • 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:
    nullPass Icon
  • 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'.Pass Icon
  • In that last example, our function returns a null | string, but our n variable has a type of StringToNull<string | number>, which reduces to null | number.

  • Now, how does the StringToNull type 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 StringToNull is 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. Is string extends string true? Yes, so we replace string with null.
    • The second union alternative is number. Is number extends string true? No, so we leave number alone.
    • We've now applied the conditional type to both union alternatives. It gave us the new types null and number. Combine them back into a new union, giving null | number. That's the type of StringToNull<string | number>.
  • 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 Exclude type. For the rest of this lesson, we'll use our own version, OurExclude. We name it OurExclude only 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:
    1Pass Icon
  • 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'.Pass Icon
  • 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'.Pass Icon
  • Here's a code problem:

    The RemoveStringsFromUnion type below should remove the string type from any union. For example, RemoveStringsFromUnion<string | number> is just number.

    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 | never is always T. You can use that to "remove" union alternatives by replacing them with never.

    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]Pass Icon
  • 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 PropertyTypes1 and PropertyTypes2 types are the same. Both of them are string | number.

  • >
    type User = {name: string, age: number};
    type PropertyTypes1 = User['name' | 'age'];
    type PropertyTypes2 = User['name'] | User['age'];
    Result:
  • In PropertyTypes1, TypeScript "distributes" User across the two property names. Internally, the TypeScript compiler turns it into something more like PropertyTypes2: it applies User[...] to both of the property names, giving User['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!