🔄️

TypeScriptで 数値列挙型 メンバー(文字列) と 値(数値) を相互変換する

2024/12/22に公開

実現したいこと

TypeScript の 数値列挙型 で以下を実現したい

  1. enum のメンバーから値を取得
  2. enum の値からメンバーを取得
動機
  • 相互変換が必要になった背景
    バックエンドとクライアント(App/Web)間の通信を仲介する BFF 層において発生。

  • 具体的な仕様
    バックエンドのスキーマ定義ファイルから TypeScript コードを自動生成するライブラリでは、enum が数値列挙型として生成される仕様だった。

  • 課題

    • 上述の仕様により、バックエンドから受け取る enum 値は数値として扱われる。
    • 一方、クライアントは enum メンバー(文字列)を期待する。
  • 必要な変換

    • クライアント → BFF → バックエンド:メンバーから値への変換
    • バックエンド → BFF → クライアント:値からメンバーへの変換

TL;DR

以下を採用しました。

convert-between-enum-and-string.ts
enum RoleEnum {
  ADMIN = 0,
  READ_ONLY = 1,
}

type RoleMember = keyof typeof RoleEnum;

const RoleMemberObj: Record<RoleMember, RoleMember> = {
  ADMIN: 'ADMIN',
  READ_ONLY: 'READ_ONLY',
};

const isRoleMember = (str: string): str is RoleMember => {
  return str in RoleMemberObj;
};

const convertStrToEnum = (str: string): RoleEnum => {
  if (isRoleMember(str)) {
    return RoleEnum[str];
  } else {
    throw new Error(`RoleEnum[${str}] はcompile error.`);
  }
};

const convertEnumToStr = (enumVal: RoleEnum): RoleMember => {
  if (isRoleMember(RoleEnum[enumVal])) {
    return RoleEnum[enumVal];
  } else {
    throw new Error(
      `${RoleEnum[enumVal]} はundefiend (compile errorではない).`
    );
  }
};

型ガード関数の引数を string とすることで、enum のメンバーからでも、enum の値からでも、その引数に対して型情報を与えることができます。

const isRoleMember = (str: string): str is RoleMember => {
  return str in RoleMemberObj;
};

比較

パターン(実装は下記参照、ループ回数 1000000 回) str -> enum 速度(ms) str -> enum メモリ(MB) enum -> str 速度(ms) enum -> str メモリ(MB)
member object(上述の実装) 1.3112 RSS: 0.13
Heap Used: 0.07
1.4312 RSS: 0.08
Heap Used: 0.07
pair object 1.3151 RSS: 0.09
Heap Used: 0.07
1.1735 RSS: 0.09
Heap Used: 0.06
array 4.1215 RSS: 0.14
Heap Used: 4.14
3.6270 RSS: 0.08
Heap Used: 3.89
map 15.8714 RSS: 8.66
Heap Used: 9.05
178.0986 RSS: 8.67
Heap Used: 4.72

比較実装

pair_object.ts
// pair object
const RolePairObj: Record<RoleMember, RoleEnum> & Record<RoleEnum, RoleMember> =
  {
    ADMIN: RoleEnum.ADMIN,
    READ_ONLY: RoleEnum.READ_ONLY,
    [RoleEnum.ADMIN]: 'ADMIN',
    [RoleEnum.READ_ONLY]: 'READ_ONLY',
  };

const isRoleMemberForPairObj = (str: string): str is RoleMember => {
  return str in RoleMemberObj;
};

const convertStrToEnumByPairObj = (str: string): RoleEnum => {
  // pair object でも型ガード関数は必要
  if (isRoleMemberForPairObj(str)) {
    return RoleEnum[str];
  } else {
    throw new Error(`RoleEnum[${str}] はcompile error.`);
  }
};

const convertEnumToStrByPairObj = (enumVal: RoleEnum): RoleMember => {
  return RolePairObj[enumVal];
};
array.ts
// array
const RolePairArr: {
  roleMember: RoleMember;
  roleEnum: RoleEnum;
}[] = [
  { roleMember: 'ADMIN', roleEnum: RoleEnum.ADMIN },
  { roleMember: 'READ_ONLY', roleEnum: RoleEnum.READ_ONLY },
];

const convertStrToEnumByArr = (str: string): RoleEnum | undefined => {
  return RolePairArr.find((role) => role.roleMember === str)?.roleEnum;
};

const convertEnumToStrByArr = (enumVal: RoleEnum): RoleMember | undefined => {
  return RolePairArr.find((role) => role.roleEnum === enumVal)?.roleMember;
};
map.ts
// map
const RolePairMap = new Map<RoleMember, RoleEnum>([
  ['ADMIN', RoleEnum.ADMIN],
  ['READ_ONLY', RoleEnum.READ_ONLY],
]);

const isRoleMemberForMap = (str: string): str is RoleMember => {
  return str in RoleMemberObj;
};

const convertStrToEnumByMap = (str: string): RoleEnum | undefined => {
  if (isRoleMemberForMap(str)) {
    return RolePairMap.get(str);
  }
};

const convertEnumToStrByMap = (enumVal: RoleEnum): RoleMember | undefined => {
  return [...RolePairMap.entries()].find(
    ([, roleEnum]) => roleEnum === enumVal
  )?.[0];
};
測定実装
measure.ts
const logMemoryUsage = (
  message: string,
  recent: NodeJS.MemoryUsage,
  compare?: NodeJS.MemoryUsage
) => {
  console.log(message);
  console.log(
    `RSS: ${((recent.rss - (compare?.rss ?? 0)) / 1024 / 1024).toFixed(2)} MB`
  );
  console.log(
    `Heap Used: ${(
      (recent.heapUsed - (compare?.heapUsed ?? 0)) /
      1024 /
      1024
    ).toFixed(2)} MB`
  );
	console.log('---------------------');
};

const measure = (func: () => any, times = 1e6) => {
  const beforeMemory = process.memoryUsage();
  logMemoryUsage('Before Execution', beforeMemory);

  performance.mark('start');

  for (let i = 0; i < times; i++) {
    func();
  }

  performance.mark('end');
  performance.measure('Execution Time', 'start', 'end');
  const afterMemory = process.memoryUsage();
  logMemoryUsage('After Execution', afterMemory);

  logMemoryUsage('Memory Usage Diff', afterMemory, beforeMemory);

  const timeMeasure = performance.getEntriesByName('Execution Time');
  console.log(`Execution Time: ${timeMeasure[0].duration}ms`);
};

const main = () => {
  const str: string = 'ADMIN';

  measure(() => convertStrToEnum(str));
};

main();

採用理由

  • member object はシンプルな実装であり、型を操作する必要が少ない。
  • パフォーマンス面では pair object が優れているが、型定義が冗長になる場合がある。
型定義が冗長になる今回のケース
  • 型に工夫とは:今回は スキーマ、ライブラリの仕様により不要な enum 値が generate されてしまうので、それを除く必要があった
実際の例
enum RoleEnum {
  UNSPECIFIED = 0,
  ADMIN = 1,
  READ_ONLY = 2,
}

type RoleMember = Exclude<keyof typeof RoleEnum, 'UNSPECIFIED'>;
type RoleEnumType = Exclude<RoleEnum, RoleEnum.UNSPECIFIED>;

// UNSPECIFIED が不要だが、convertEnumToStrByPairObj()の型エラーを防ぐためObjectには必要
// 型が冗長になってしまう
const RolePairObj: Record<RoleMember, RoleEnum> & Record<RoleEnum.UNSPECIFIED, undefiend> & Record<RoleEnumType, RoleMember> =
  {
    ADMIN: RoleEnum.ADMIN,
    READ_ONLY: RoleEnum.READ_ONLY,
    [RoleEnum.UNSPECIFIED]: undefined,
    [RoleEnum.ADMIN]: 'ADMIN',
    [RoleEnum.READ_ONLY]: 'READ_ONLY',
  };

const convertEnumToStrByPairObj = (enumVal: RoleEnum): RoleMember | undefiend => {
  return RolePairObj[enumVal];
};

あとがき

本当なら変換が必要ないような API 設計にできればこのようなことを考えなくても良かったのだろうとは思ってます...。

ただ、やむにやまれず必要になることもあると思い、検索しても型アサーションせずに実現して、かつ比較している情報は見つからなかったのでまとめました。

GitHubで編集を提案

Discussion