Complete guide to type distribution in TypeScript

May 29, 2021

Hello everyone! In first article about type-safe get and set function, I’ve mentioned that TS has a thing called type distribution.

I’ve briefly touched on it, however, there are more details about it, and I thought I’d create a separate article going in-depth on this topic.

Type distribution, for those who don’t know, is a feature of a compiler that applies conditionals to each item of generic union types, not to a union as a whole.

For instance:

type StrOrNumber = string | number;
type NotDistributed = StrOrNumber extends number ? 'correct' : 'incorrect'; // 'incorrect'

type IsNumber<T> = T extends number ? 'correct' : 'incorrect';
type Distributed = IsNumber<StrOrNumber>; // 'correct'|'incorrect'

You can see that the type of Distributed is union because the condition was applied to each item of the union. But NotDistributed was computed to incorrect because the condition was applied to StrOrNumber as a whole, not to each item of it.

The first case, with NonDistributed, makes sense since string | number is not assignable to number:

declare const b: string | number;
const a: string = b;
//    ^
// Type 'string | number' is not assignable to type 'string'.
//   Type 'number' is not assignable to type 'string'.ts(2322)

But, to make the generic types work correctly with unions, the TS treats the union as an “array” of types in conditionals.

Now, let’s try to understand in which particular cases generic types get and don’t get distributed.

As I already said, the distribution happens in conditionals, however, it happens only when the conditional is directly applied to the generic type.

So there are few cases where it won’t work:

  • Generic type is used on the right side of the extends condition
  • Property access inside conditional
  • Usage of the keyof on the generic type
  • Mapped types with key matching

Generic type is used on the right side of the extends condition

type IsLiteralNumber<T extends number> = number extends T ? false : true;

In the type IsLiteralNumber above, the distribution won’t happen:

// added `{}` to number so the union
// doesn't get merged to `number`
type Num = 5 | 6 | (number & {});

type Test = IsLiteralNumber<Num>;

This happens because the condition number extends T doesn’t operate on T, since T is used as a constraint here.

Property access inside conditional

Ok, now let’s check another case where type distribution won’t work:

type IsArrayLike<T extends Record<string, any>> = T['length'] extends number
  ? true
  : false;

type ArrayOrObject = [1, 2, 3] | { a: number };

type Test = IsArrayLike<ArrayOrObject>; // false

The value type of Test is false, therefore the distribution didn’t get applied. This happens due to the reason that our conditional operates on T['length'], not T.

Usage of the keyof on the generic type

Another reason why distribution doesn’t happen may be usage of keyof on the generic type:

type ArrayOrObject = [1, 2, 3] | { a: number };

type ObjectOnlyWithWithA<T> = keyof T extends 'a' ? true : false;

type Test = ObjectOnlyWithWithA<ArrayOrObject>; // true

In the example above, if the distribution was correct, Test would be boolean(true | false). But because of the keyof usage T gets treated as a single union and we get an incorrect result.

Mapped types with key matching

Next area where the distribution doesn’t work is mapped types with key matching:

type ObjectUnion = { a(): number } | { b(): number };

type AnyFunction = (...args: any[]) => any;

type FunctionKeys<T extends Record<string, any>> = {
  [K in keyof T]: T[K] extends AnyFunction ? K : never;
}[keyof T];

type Test = FunctionKeys<ObjectUnion>; // never

You can see that Test is never because keyof T will return never. This happens due to the reason that keyof returns publicly accessible keys of a type. Since items of ObjectUnion don’t have overlapping keys, neither a nor b is accessible, since they may not be present.

How to force type distribution

After we’ve covered the cases where type distribution doesn’t work, you are likely saying:

“Ayub, that’s great, but how to fix this?”

That’s a good question.

And since we now know how distribution works, we may force complier to distribute our generic type.

Let’s take an example with IsLiteralNumber type:

type IsLiteralNumber<T extends number> =
+ T extends unknown
    ? number extends T
      ? false
      : true
+    : never;

We intentionally added conditional T extends unknown so that some condition is applied to our T.

We could do the same thing with FunctionKeys type:

type ObjectUnion = { a(): number } | { b(): number };

type AnyFunction = (...args: any[]) => any;

type FunctionKeys<T extends Record<string, any>> = T extends unknown
  ? {
      [K in keyof T]: T[K] extends AnyFunction ? K : never;
    }[keyof T]
  : never;

type Test = FunctionKeys<ObjectUnion>; // 'a' | 'b'

You could see that right now everything works like a charm.

However, I advise library creators to not overuse this tactic, since it makes the compiler do more work. One solution could be to create 2 types, one non distributed, second - distributed that will leverage the first one in the implementation:

type IsLiteralNumber<T extends number> = T extends unknown
  ? IsLiteralNumberNonDistributed<T>
  : never;

type IsLiteralNumberNonDistributed<T> = number extends T ? false : true;

So when you know for sure that your type is already distributed, you can use non distributed version.

Note that this is advice is more applicable to the library creators since you rarely have computation heavy types in application/business logic code.

How to prevent type distribution

Now, after we’ve touched on the points where type distribution doesn’t happen and how to force it, let’s discuss how to prevent your types from getting distributed.

First of all, let’s try to look at the situation where it can be useful:

type AreUnionsAssignable<U1, U2> = U1 extends U2 ? true : false;

type Test = AreUnionsAssignable<string | number, number | symbol>;

The AreUnionsAssignable needs to check if unions assignable to each other. So the type of Test is boolean(true | false) right now, but it’s clear that these unions aren’t assignable to each other:

declare let a: string | number;
declare let b: number | boolean;

a = b; /*
^
Type 'number | boolean' is not assignable to type 'string | number'.
  Type 'boolean' is not assignable to type 'string | number'.ts(2322)
*/

The reason why this happens is because U1 gets distributed, and since string | number and number | symbol have overlap (number), the result is true or false which gets simplified to boolean.

So to fix this problem we must not operate the condition on the U1 type, here is one way of doing it:

type AreUnionsAssignable<U1, U2> = [U1] extends [U2] ? true : false;

type Test = AreUnionsAssignable<string | number, number | symbol>; // false

We simply wrapped our generics (U1 and U2) into square brackets so that condition operates on an array with generic items, not a generic item itself.

Conclusion

Type distribution is an important topic that you need to understand to write type-safe code. Especially if you are a library creator.

So next time you write a utility type in your project, make sure to check that it works correctly with unions, and if it’s not, you know how to fix it.

That’s it for today if you liked the article - share it with your friends.

Take care.


© 2023 Ayub Begimkulov All Rights Reserved