DEV Community

Cover image for A utility type for converting union to set in TypeScript
Suiko
Suiko

Posted on • Originally published at suiko.dev

A utility type for converting union to set in TypeScript

Motivation

Assume you are working on a Todo app.
homer's Todo List
This app uses an HTTP API to retrieve all of the to-do items. You and your back-end colleagues agree on a data type for to-do:

type Todo = {
  id: number;
  title: string;
  description: string;
  /**
   * 0 means active, 1 means completed
   */
  status: 0 | 1;
};
Enter fullscreen mode Exit fullscreen mode

This Todo app contains a select component for filtering out active/completed items, and you could build the following data structure to represent all of the possible options:

const statusFilterOptions = [
  {
    value: 0,
    label: "active",
  },
  {
    value: 1,
    label: "completed",
  },
];
Enter fullscreen mode Exit fullscreen mode

You may define a type for this data structure to avoid a coworker or yourself from accidentally outputting a bug while refactoring this code later:

type Option = {
  value: Todo["status"];
  label: string;
};

const statusFilterOptions: Option[] = [
  {
    value: 0,
    label: "active",
  },
  {
    value: 1,
    label: "completed",
  },
];
Enter fullscreen mode Exit fullscreen mode

In general, if your team is simply using Typescript, this is sufficient. However, if we want to be more specific about the type of statusFilterOptions, we may go much farther here. The following examples of statusFilterOptions, while satisfying the Option type, will cause bugs in the select component used to filter out incomplete/completed items in a production environment:

// some value in options
const statusFilterOptions: Option[] = [
  {
    value: 1,
    label: "active",
  },
  {
    value: 1,
    label: "completed",
  },
];

// missing one option
const statusFilterOptions: Option[] = [
  {
    value: 1,
    label: "active",
  },
];

// option is overwritten by one
const statusFilterOptions: Option[] = [
  {
    value: 0,
    label: "active",
  },
  {
    value: 1,
    label: "completed",
  },
  {
    value: 2,
    label: "completed",
  },
];
Enter fullscreen mode Exit fullscreen mode

Not only to eliminate the above bug cases, but consider a real-world business scenario in which the product may change frequently, and the status of todo may become 0, 1 and 2 later on. To prevent the situation where only the Todo type is modified and the filter options are not modified at the same time, we can write a type that can be widely used in such scenarios.

Coding

Consider our type's input and output first. We expect the input type to be a set like 0 | 1 or male | female, and the expected output type should be an array containing all the elements of the set precisely, one cannot be more, one cannot be less 🙅, and the order of the array elements is adjustable. It is easy to conclude that what we need is a type that converts union to set. The following is the UnionToSet code.

type UnionToSet<
  U extends string | number | symbol,
  R extends (string | number | symbol)[] = []
> = {
  [S in U]: Exclude<U, S> extends never
    ? [...R, S]
    : UnionToSet<Exclude<U, S>, [...R, S]>;
}[U];
Enter fullscreen mode Exit fullscreen mode

The above code implements the conversion by recursively traversing U. The three following points should be noticed at ⚠️:

  • Uses the heavyweight features provided by TypeScript version 4.0: Variadic Tuple Types。Therefore, it can only be used in TypeScript 4.0 and above.
  • Based on recursion. Although TypeScript 4.5 is optimized for tail recursion,Although TypeScript 4.5 is optimized for tail recursion, the UnionToSet cannot be converted to tail recursion mode, so please do not use it when the collection is too large.
  • JavaScript Object key type limitation. Because of the JavaScript object key limitation, TypeScript is limited to the type of U in '[S in U]' must inherit from'string | numebr | symbol', therefore a union such as 'true | false' cannot utilize this type. Of course, because the Boolean type has only two values, Even if you make a typo on True or False, your IDE will tell you, so it is not essential to restrict the type precisely.

Here we can do a small optimization, in the above code, U inherits from string | number, R inherits from string | number, so we can abstract this type and mask U and R to the user:

type UnionToSetHelper<
  T extends keyof any,
  U extends T = T,
  R extends T[] = []
> = {
  [S in U]: Exclude<U, S> extends never
    ? [...R, S]
    : UnionToSetHelper<T, Exclude<U, S>, [...R, S]>;
}[U];

export type UnionToSet<T extends keyof any> = UnionToSetHelper<T>;
Enter fullscreen mode Exit fullscreen mode

The following are examples of 'UnionToSet' test cases:

const a: UnionToSet<0 | 1> = [0, 1]; // ✅
const a: UnionToSet<0 | 1> = [1, 0]; // ✅
const b: UnionToSet<0 | 1> = [0, 1, 2]; // ❎
const c: UnionToSet<0 | 1> = [0]; // ❎
const d: UnionToSet<0 | 1> = [1, 1]; // ❎
Enter fullscreen mode Exit fullscreen mode

Based on the UnionToSet type we built, we can simply code a Options type that is strongly associated with real-world business::

type Option<T extends keyof any> = {
  value: T;
  label: string;
};

type OptionsHelper<
  T extends keyof any,
  U extends T = T,
  R extends Option<T>[] = []
> = {
  [S in U]: Exclude<U, S> extends never
    ? [...R, Option<S>]
    : OptionsHelper<T, Exclude<U, S>, [...R, Option<S>]>;
}[U];

type Options<T extends keyof any> = OptionsHelper<T>;
Enter fullscreen mode Exit fullscreen mode

The following is the Todo demo in this article; you may attempt to edit the 'Todo' type in'src/App.tsx' after forking:

Bonus

This article concentrates on the situation of 'union to set', and it's logical to question if 'union' can be converted to 'tuple'(sorted set).
You can jump to this issue to see what people are talking about. This question is also available in type-challenge.

In the end

I'd love to hear your feedback and suggestions❤️, you can find this post on my website.

Top comments (1)

Collapse
 
Sloan, the sloth mascot
Comment deleted