Skip to content

Instantly share code, notes, and snippets.

@jbreckmckye
Last active October 12, 2020 12:00
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jbreckmckye/4294ef9639a6d3d6cfebfd299dd229ef to your computer and use it in GitHub Desktop.
Save jbreckmckye/4294ef9639a6d3d6cfebfd299dd229ef to your computer and use it in GitHub Desktop.
TypeScript hack: getting access to the members of a union

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"'.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment