I had some fun with TypeScript union distributions this weekend. This is a (kinda) hack around conditional types that lets you access the individual members of a union in a generic type.
A usecase for this might be when your function accepts items from a union of types, but you want all your function parameters to be consistent in which 'branch' of the union they specify.
Let's say you have a type representing some events (this is a contrived example but simple)
type Events =
| { kind: 'loading', data: void }
| { kind: 'error', data: Error }
| { kind: 'success', data: string }
(This pattern - a union of structs with a discriminating string key - is called 'discriminated unions'. In the Haskell world we call them 'tagged unions')
And a function that sends events
function sendEvent (kind: string, data: any) {
someBus.send(kind, data);
}
I want to type the sendEvent
function better so that
- the "kind" is a correct event kind
- the "data" is the right data for the kind of event
(Typically it would be easier just to take a complete object, which would automatically enforce the consistency. This is just a simplified example to demonstrate the problem)
Typing the kind string is easy
function sendEvent (kind: Events['kind'], data: any) {}
What about data? Well, I could type it the same way as my kind
function sendEvent (kind: Events['kind'], data: Events['data']) {}
But now I have a problem: my kind
and my data
can mismatch
sendEvent('error', 'a string? oh man you just messed up'); // no type errors!
Well. What I need to do is create a type mapping that uses a type condition. Any type condition will do.
type NarrowByKind <Kind, Items extends { kind: string }> = Items extends any
? Items['kind'] extends Kind
? Items
: never
: never;
This lets us pick an item out of a collection of discriminated unions using kind
as the discriminant. This is the first step.
The way it works is that Items extends any ? ... : ...
makes TS consider each member of the union individually in the branches of the type condition. Then, when we check whether the kind
matches and return Items
, we're only returning the individual item.
(TypeScript has to do this or else mapping a union over a conditional type wouldn't make sense. You'd be testing that a whole union matches a particular condition, which would generally fail as unions are usually heterogeneous)
Anyway, for all other conditions, we return never
. This makes NarrowByKind
return a union itself of Item | never
, which gets normalised down to just Item
.
Now let's create a type helper for our events.
type EventData <Kind, Event extends { data: any } = NarrowByKind<Kind, Events>> = Event['data']
Which we can supply to our function:
function sendEvent <K extends Events['kind'], D extends EventData<K>> (kind: K, data: D) {
someBus.send(kind, data);
}
Which leads to the following type checks:
sendEvent('success', 'yeah'); // ✅
sendEvent('success', false); // ❌ Argument of type 'boolean' is not assignable to parameter of type 'string'.
sendEvent('error', new Error('this is fine')); // ✅
sendEvent('error', -Infinity); // ❌ Argument of type 'number' is not assignable to parameter of type 'Error'.
sendEvent('loading', 'unwanted'); // ❌ Argument of type 'string' is not assignable to parameter of type 'void'.
sendEvent('succ3ss', false); // ❌ Argument of type '"succ3ss"' is not assignable to parameter of type '"loading" | "error" | "success"'.