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.