DEV Community

Chau Tran
Chau Tran

Posted on

TypeScript Mapped Union Type

Recently, a coworker at Nx approaches me with a TypeScript problem
that we both thought "It seems simple" at first. We soon find that it's not as simple as we thought. In this blog post, I'll walk you through the problem, the seems to be solution, the solution, and the thought process behind them.

The Problem

declare function table(items: any[], fieldOptions: any[]): void;
Enter fullscreen mode Exit fullscreen mode
  • We have a function that accepts some collection of items and a collection of fieldOptions that should be strongly typed to the type of each individual items

    declare function table(items: any[], fieldOptions: any[]): void;
    
    const items = [
        {
            foo: "some foo",
            bar: 123,
        },
        {
            foo: "some foo two",
            bar: 456,
        },
    ];
    
  • From this usage, items has a type of Array<{foo: string, bar: number}> and fieldOptions needs to be strongly typed against {foo: string, bar: number}. Usage of table() can be as follow

    const items = [
        {
            foo: "some foo",
            bar: 123,
        },
        {
            foo: "some foo two",
            bar: 456,
        },
    ];
    
    table(items, [
        "foo",
        {
            field: "bar",
            mapFn: (val) => {
                // should return something. eg: a string
            },
        },
    ]);
    

    Here, we can see that fieldOptions can accept each key of {foo: string, bar: number}, aka 'foo' | 'bar'. In addition, fieldOptions can also accept a FieldOption object that has a field: 'foo' | 'bar' as well as mapFn callback that will be invoked with the value at {foo: string, bar: number}[key]. In other words, when we pass field: "bar", mapFn then needs to have type: (value: number) => string because bar has number as its valuet type.

The "seems to be" Solution

At first glance, it seems easy. We'll go through each step.

  • First, table() needs to accept a generic to capture the type of each item in items collection

    - declare function table(items: any[], fieldOptions: any[]): void;
    + declare function table<TItem>(items: TItem[], fieldOptions: any[]): void;
    

    In addition, we also like to constraint TItem to an object so the consumers can only pass in a collection of objects

    - declare function table<TItem>(items: TItem[], fieldOptions: any[]): void;
    + declare function table<TItem extends Record<string, unknown>>(items: TItem[], fieldOptions: any[]): void;
    

    TItem extends Record<string, unknown> is the constraint

  • Second, we need a type for fieldOptions. This type needs to accept a generic that is an object so that we can iterate through the object keys

    + type FieldOption<TObject extends Record<string, unknown>> = keyof TObject | {
    +     field: keyof TObject;
    +     mapFn: (value: TObject[keyof TObject]) => string;
    + }
    
    declare function table<TItem extends Record<string, unknown>>(
        items: TItem[],
    -   fieldOptions: any[],
    +   fieldOptions: FieldOption<TItem>[]
    ): void;
    

    With the above type declaration, FieldOption<{foo: string, bar: number} is as follow

    type Test = FieldOption<{ foo: string; bar: number }>;
    //    ^?    'foo' |
    //          'bar' |
    //          {
    //              field: 'foo' | 'bar';
    //              mapFn: (value: string | number) => string;
    //          }
    //
    
  • This seems correct but when we apply it, we find that the type isn't as strict as we like

    const items = [{ foo: "some foo", bar: 123 }];
    
    table(items, [
        {
            field: "foo",
            mapFn: (value) => {
                // ^? value: string | number
            },
        },
    ]);
    

    We like for value to have type of string instead of a union string | number because we specify the "foo" for field. Hence, {foo: string, bar: number}['foo'] should be string

  • The problem is that TypeScript cannot narrow down mapFn from field because we cannot constraint them the way we currently have our type. Our next step is to try having keyof TItem as a generic as well hoping that TypeScript can infer it from field

    type FieldOption<
        TItem extends Record<string, unknown>,
        TKey extends keyof TItem = keyof TItem
    > =
        | TKey
        | {
              field: TKey;
              mapFn: (value: TKey) => string;
          };
    

    But it still won't work because we can never pass a generic in for TKey and TKey is always defaulted to keyof TItem which will always be 'foo' | 'bar' for our {foo: string, bar: number} item type. So as of this moment, we're stuck.

We spent a good 15-20 minutes trying things out but to no avail.

The Solution

Out of frustration, I asked for help in trashh_dev Discord and Tom Lienard provided the solution with a super neat trick. I'll attempt to go through the thought process to understand the solution

What we're stuck on is we're so hung up on the idea of FieldOption needs to be an object type {field: keyof TItem, mapFn} but in reality, what we actually need is as follow

// let's assume we're working with {foo: string, bar: number} for now instead of a generic to simplify the explanation

// what we think we need
type FieldOption = {
    field: "foo" | "bar";
    mapFn: (value: string | number) => string;
};

// what we actually need
type FieldOption =
    | {
          field: "foo";
          mapFn: (value: string) => string;
      }
    | {
          field: "bar";
          mapFn: (value: number) => string;
      };
Enter fullscreen mode Exit fullscreen mode

Yes, we need a Mapped Union from our TItem instead of a single object with union properties. The question is how we get to the Mapped Union. Well, it is a 2-step process

  • We need to convert TItem into a Mapped Type
type FieldOption<TItem extends Record<string, unknown>> = {
    [TField in keyof TItem]: {
        field: TField;
        mapFn: (value: TItem[TField]) => string;
    };
};

type Test = FieldOption<{ foo: string; bar: number }>;
//   ^? {
//          foo: { field: 'foo'; mapFn: (value: string) => string };
//          bar: { field: 'bar'; mapFn: (value: number) => string };
//      }
Enter fullscreen mode Exit fullscreen mode
  • We need to map over the Mapped Type with keys of TItem to get the Mapped Union
type FieldOption<TItem extends Record<string, unknown>> = {
    [TField in keyof TItem]: {
        field: TField;
        mapFn: (value: TItem[TField]) => string;
    };
}[keyof TItem];

type Test = FieldOption<{ foo: string; bar: number }>;
//   ^? | { field: 'foo'; mapFn: (value: string) => string }
//      | { field: 'bar'; mapFn: (value: number) => string }
Enter fullscreen mode Exit fullscreen mode

Now, let's try using table() with our Mapped Union type to see if it works

type FieldOption<TItem extends Record<string, unknown>> =
    | keyof TItem
    | {
          [TField in keyof TItem]: {
              field: TField;
              mapFn: (value: TITem[TField]) => string;
          };
      }[keyof TItem];

declare function table<TItem extends Record<string, unknown>>(
    items: TTem[],
    fields: FieldOption<TItem>[]
): void;

const items = [
    { foo: "string", bar: 123 },
    { foo: "string2", bar: 1234 },
];

table(items, [
    {
        field: "foo",
        mapFn: (value) => {
            //  ^? value: string
            return "";
        },
    },
    {
        field: "bar",
        mapFn: (value) => {
            //  ^? value: number
            return "";
        },
    },
]);
Enter fullscreen mode Exit fullscreen mode

And that is our solution. So simple, yet so powerful trick. Here's the TypeScript Playground that you can play with.

If anyone knows the correct term for Mapped Union, please do let me know so I can update the blog post.

Top comments (1)

Collapse
 
seanmay profile image
Sean May • Edited

What you are looking at is two different concepts... the record-building and accessing the key, thereof, is its own thing.

The concept of

type Person = {
  kind: "Person";
  name: string;
  surname: string;
};

type Persona = {
  kind: "Persona";
  name: string;
};

type Celebrity =
  | Person
  | Persona;

const celebs: Celebrity [] = [
  { kind: "Person", name: "David", surname: "Gilmour" },
  { kind: "Persona", name: "Cher" },
];

celebrities
  .filter<Person>((x): x is Person => x.kind === "Person")
  .map(x => x.surname); // => ["Gilmour"]
Enter fullscreen mode Exit fullscreen mode

is reliant on "Discriminated Unions" (or tagged unions). That being a type union where every type in the union shares a common tag, and the value for each tag is unique to that type. Celebrity has "kind" and the value uniquely identifies Person and Persona, and if we add "Mascot" later, and "Influencer" and whatever else, that "kind" will continue to be the key we use to look up the unique properties of that particular type.

The concept of "tagged unions" requires everything to share a tag, but that's not the only benefit to unions.

It also applies in the case where you have many optional properties that are mutually inclusive.

You might have, I dunno, a color object that is trying to store information that gets turned into CSS or Canvas ImageData or whatever...

Instead of having a color type which can optionally handle RGB, HSL and HSV:

type Color = {
  r?: number;
  g?: number;
  b?: number;
  h?: number;
  s?: number;
  l?: number;
  v?: number;
}; // { b: 1} is valid here... so is { v: 2 } and { }
Enter fullscreen mode Exit fullscreen mode

...which is bad, because you essentially have 2^7 (128) possible types, when you only meant to have 3...

you probably meant to do something like:

type Num = number; // just saving space on phone
type RGB = { mode: "rgb"; r: Num; g: Num; b: Num; };

type HSL = { mode: "hsl"; h: Num; s: Num; l: Num };

type HSV = { mode: "hsv"; h: Num; s: Num; v: Num; };

type Color =
  | RGB
  | HSL
  | HSV;


const CSSColorConverters = {
  rgb: (rgb: RGB) => `...`,
  hsl: (hsl: HSL) => `...`,
  hsv: (hsv: HSV) => `...`,
} as const;

const toCSSColor = (c: Color): string => {
  // this is a discriminated union; I could use "mode" here
  // Nota bene: this might require a little more coaxing depending on TS version
  return CSSColorConverters[c.mode](c);
};
Enter fullscreen mode Exit fullscreen mode

but I wouldn't need to use mode exclusively, here, TS knows that each type has keys that the other types must not have.

if ("r" in c)
  return convertRGB(c);

if ("l" in c)
  return convertHSL(c);

if ("v" in c)
  return convertHSV(c);
Enter fullscreen mode Exit fullscreen mode

If these were all optional types (or each key was its own union decoupled from all of the other keys), this type of check might be impossible, or require a nonsensical level of r && b && g || h && s && l || h && s && v style checking. Discriminated Unions for the save.