A utility type for converting union to set in TypeScript
Suiko
Posted on February 4, 2023
Motivation
Assume you are working on a Todo app.
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;
};
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",
},
];
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",
},
];
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",
},
];
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];
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>;
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]; // ❎
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>;
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.
Posted on February 4, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
April 29, 2024