0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

タプルをenumライクなオブジェクトに変換する

Last updated at Posted at 2020-09-19

背景

TypeScriptのenumを使うのはなるべく避けたい...

TypeScriptのenumはJavaScriptにはない機能です。
これはこれで便利なのですが、JavaScriptにトランスパイルされる際にJavaScriptにはない機能を使って変換されます。
特にenumの場合は以下のように即時実行関数式に変換されるので、バンドルサイズも大きくなってしまいます。

TypeScript
enum OperatingSystem {
  MacOS,
  Windows,
  Linux
};
JavaScript
let OperatingSystem;
(function (OperatingSystem) {
    OperatingSystem[OperatingSystem["MacOS"] = 0] = "MacOS";
    OperatingSystem[OperatingSystem["Windows"] = 1] = "Windows";
    OperatingSystem[OperatingSystem["Linux"] = 2] = "Linux";
})(OperatingSystem || (OperatingSystem = {}));
; 

他にも様々なデメリットがあるようです。
詳しくは以下の記事で解説されています。

いちいちプロパティ名と値を書くのはなんか面倒だな

次のように書くとオブジェクトをenumのように使うことはできます。

const OperatingSystem = {
  MacOS: 0,
  Windows: 1,
  Linux: 2
} as const;
type OperatingSystem = typeof OperatingSystem[keyof typeof OperatingSystem];

しかし、プロパティ名と値の文字列が同じだったり、enumだと値を省略すれば0から値が順番に入っていたのを手作業で書くのを繰り返していくと、「俺はこんなことをするためにプログラミングをしたいんじゃないんだ...!」という気持ちに襲われます。

次のようにプロパティ名と値が微妙に異なるケースもあると思います。

const OperatingSystem = {
  MacOS: 'macOS', // 値はmacOSにしたい
  Windows: 'Windows',
  Linux: 'Linux'
} as const;
type OperatingSystem = typeof OperatingSystem[keyof typeof OperatingSystem];

タプルをenumライクなオブジェクトに変換する関数を定義しよう

使用例

次のように引数にタプルを受け取り、それに基づくオブジェクトを生成する関数を定義したいです。
おまけに第2引数にオプションを指定するだけで値を数値にするか決められるようにしたいです。

const OperatingSystem = enumObject(['macOS', 'Windows', 'Linux'] as const);
const macOS = OperatingSystem.MacOS; // 'macOS'

const OperatingSystem = enumObject(['macOS', 'Windows', 'Linux'] as const, { index: true });
const macOS = OperatingSystem.MacOS; // 0
// 関数の戻り値の型はOperatingSystemの型は次のように生成されるようにしたい
type OperatingSystem = {
  readonly MacOS: 'macOS';
  readonly Windows: 'Windows';
  readonly Linux: 'Linux';
}

これは以下のようなユーティリティ型も実装しておくと非常に便利です。

type Values<T> = T[keyof T];
const OperatingSystem = enumObject(['macOS', 'Windows', 'Linux'] as const);
type OperatingSystem = Values<typeof OperatingSystem>;

let os: OperatingSystem; // 'macOS' | 'Windows' | 'Linux'
os = OperatingSystem; // Error!
os = OperatingSystem.Linux; // 'Linux'

実装

enumObject関数の定義は以下のとおりです。Playground
注意点として、macOS -> MacOSのような型の変換にはTypeScript 4.1のtemplate literal typesを使っています。

function enumObject<T extends ReadonlyArray<string>>(tuple: T): EnumString<T>;
function enumObject<T extends ReadonlyArray<string>>(tuple: T, options: { index: true }): EnumNumber<T>;
function enumObject<T extends ReadonlyArray<string>>(tuple: T, options?: { index: true }) {
  const capitalize = (str: string) => [str[0].toUpperCase(), str.slice(1)].join('');
  return tuple.reduce((obj, prop, index) => {
    obj[capitalize(prop)] = options?.index ? index : prop;
    return obj;
  }, Object.create({}));
}

type EnumString<T extends ReadonlyArray<string>>
 = { readonly [K in T[number] as `${Capitalize<K>}`]: K };

type EnumNumber<T extends ReadonlyArray<string>>
 = { readonly [K in keyof T & NumberLiteral<T> as `${Capitalize<T[K]>}`]: K };

type NumberLiteral<T extends ReadonlyArray<any>, U extends number = 0> =
  T extends []
  ? U
  : T extends [any, ...infer Rest]
    ? NumberLiteral<Rest, Rest["length"] | U>
    : never;

解説

まず、関数の型定義についてですが、この部分では関数のオーバーロードを用いて、引数に応じて戻り値の型を分けてます。

// 引数がtupleだけのときはEnumString<T>
function enumObject<T extends ReadonlyArray<string>>(tuple: T): EnumString<T>;
// 第2引数が定義されているときはEnumNumber<T>
function enumObject<T extends ReadonlyArray<string>>(tuple: T, options: { index: true }): EnumNumber<T>;

EnumString<T>template literal typesとMapped Types内でのas句を使うことでプロパティ名をキャメルケース(パスカルケース)に変換しています。

type EnumString<T extends ReadonlyArray<string>>
 = { readonly [K in T[number] as `${Capitalize<K>}`]: K };

template literal typesを使わないバージョンは以下の通りです。
T[number]と書くことで配列TのUnion Typeを取得することができます。(['a', 'b'] -> 'a' | 'b')

type Enum<T extends ReadonlyArray<string>> = { readonly [K in T[number]]: K };

EnumNumber<T>は配列の添字を使いたいのでT[number]ではなくkeyof Tを使っています。
JavaScriptにおいて配列の実態はオブジェクトなので、keyof Tには添字のプロパティの他にlengthpushなども含まれています。
そこでプロパティKと数値リテラル型の積集合をとってKを添字のプロパティのみに絞り込んでいます。
ちなみに、数値リテラル型ではなくnumberとすればenumオブジェクトの値はすべてnumber型になってしまいます。

type EnumNumber<T extends ReadonlyArray<string>>
 = { readonly [K in keyof T & NumberLiteral<T> as `${Capitalize<T[K]>}`]: K };

数値リテラル型はTS4.1で制限が緩和されたRecursive Conditional Typesを用いて再帰的に数値リテラルのユニオン型を生成しています。

type NumberLiteral<T extends ReadonlyArray<any>, U extends number = 0> =
  T extends []
  ? U
  : T extends readonly [any, ...infer Rest]
    ? NumberLiteral<Rest, Rest["length"] | U>
    : never;

type N = NumberLiteral<[1, 2, 3, 'hoge']>; // 0 | 1 | 2 | 3

inferを用いてn-1の長さの配列を取得し、配列オブジェクトの"length"プロパティを参照することで配列の長さの数値リテラル型が返されるので、それを再帰的にUに入れていって、Tが空になったところでUを返します。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?