TypeScript — Transforming a Union to Another Union

Published: Sat Apr 16 2022

This write up is based on Matt Pocock’s TypeScript tip thread.

Challenge

Say, we have a union type like the following:

type EntityPlain = { type: 'user' } | { type: 'post' }

And we’d like to transform this Entity type into some other union. This transformation could be the addition of a new property to each of the union members.

Say, we wanted to have a id property on each of the union members that includes the name of the entity type. In such a case, the desired type would look like so:

type EntityWithId =
  | { type: 'user'; userId: string }
  | { type: 'post'; postId: string }

The caveat is that we’d like to generate this new using the type properties already defined, rather than typing it out manually as above.

Solution

We can leverage a similar pattern as used in deriving a union from an object, and leverage the following TS features:

  1. mapped types to iterate over sub-types in the original type to use in the new type
  2. string literal types to concatenate the id to the type and form a type + id property
  3. the record utility type to map a property (’type’) of our existing entity type to a new type (’typeId’)
  4. indexed access type to form the final union in the desired shape

This is how it would be achieved:

type EntityWithId = {
  [EntityType in Entity['type']]: {
    [K in EntityType]: {
      type: EntityType
    } & Record<`${EntityType}Id`, string>
  }
}[Entity['type']]

Breakdown

It helps to break the solution into two steps to observe what’s going on:

  1. Recreate the original type in a more flexible form
  2. Add the new dynamic property

Step 1:

type EntityWithId = {
  // iterate over all entity types (user, post)
  // create an object type and collect them into a union
  [EntityType in Entity['type']]: {
    // iterate over entity types again and create
    // a nested object type
    [K in EntityType]: {
      type: EntityType // { type: user } | { type: post }
    }
  }
}[Entity['type']] // use indexed access type to create a union

Step 2:

Now that we have the new EntityWithId type in a more extensible form, we can do the following:

type EntityWithId = {
  [EntityType in Entity['type']]: {
    [K in EntityType]: {
      type: EntityType
    } & Record<`${EntityType}Id`, string>
  }
}

Here, we add the new property by using the Record<Keys, Type> utility type and leverage template literal type to inject theEntityType (post/user) into the property name.