🌊

TypeScriptで重複を許さない固定値を型ガードで守りたい

2024/10/05に公開

よくあるような選択肢のSelectコンポーネント。

type SelectOption = {
  value: number;
  label: string;
};
const Select = () => {
  const options: SelectOption[] = [
    { value: 1, label: 'りんご' },
    { value: 2, label: 'バナナ' },
    { value: 3, label: 'ブドウ' },
    { value: 4, label: 'もも' },
    { value: 5, label: 'ナシ' },
    { value: 6, label: 'いちご' }
   ];

  return (
    <select id='plan-select' name='plan-select'>
      {options.map(option => {
        return (
          <option value={option.value} key={option.value}>
            {option.label}
          </option>
        );
      })}
    </select>
  );
};

値が被るリスクが怖い

しかしこの選択肢が、例えば選択でユーザーに請求する値段が変わるなど、クライアントのビジネスドメイン上非常に重要な意味を持つ場合、保守拡張の際に、うっかり重複した値を設定してしてしまい、バグを引き起こすのは非常にリスクが大きい。

ぱっと見間違えることは無さそうだが、こんな綺麗に1から順に並ばなくなることも保守上ではあり得る。

例えば「出る順番を並べ替えたい」などクライアントから言われたことで、うっかり

  const options: SelectOption[] = [
    { value: 4, label: 'もも' },
    { value: 6, label: 'いちご' },
    { value: 5, label: 'ナシ' },
    { value: 2, label: 'バナナ' },
    { value: 1, label: 'りんご' },
    { value: 3, label: 'ブドウ' },
    { value: 4, label: '柿' }
   // ⇧⇧⇧ すでにvalue:4が他に存在するのに誤って新しいデータのように挿入してしまった!
   ];

などとしてしまうかもしれない。

これだとユーザーは「柿」を入力したのにバックエンドでは「桃」と認識されてしまう。

もしユーザーが柿を買ったつもりで桃が届いたら大変なことだ。

しかも記述が間違っていても、納品前に選択肢を全て検証するなど非現実だし、テストもしにくい。

なるべく不確実でリスクのある要素は静的解析レベルで防ぎたい。

解決策

TypeScriptの静的解析で数独が作れるくらいである

今回の場合、オプション値は静的に管理しているため、静的解析が効かせられるのではないかと思った。

結果このような関数を作ることになった
(Claude先生にも相談しつつ)

type EnsureUniqueValues<T extends readonly { value: string | number }[]> = {
  [K in keyof T]: T[K] extends { value: infer V } ? (Extract<T[number], { value: V }> extends T[K] ? T[K] : never) : never;
};

export function defineOptions<T extends readonly { value: string | number }[]>(options: T & EnsureUniqueValues<T>): T {
  return options;
}

type Option = {
  name: string;
  value: number;
};

EnsureUniqueValuesでやってること

T extends readonly { value: string | number }[]:

Tは、valueプロパティを持つオブジェクトの読み取り専用配列

[K in keyof T]: ...:

Tの各要素(インデックスK)に対して処理を行うマップ型。
T[K] extends { value: infer V } ? ... : never:

T[K]がvalueプロパティを持つ場合、その型をVとして抽出。

Extract<T[number], { value: V }>:

配列T内で、valueがVである要素を全て抽出。
T[number]は、配列Tの要素全体を指すユニオン型。

Extract<T[number], { value: V }> extends T[K] ? T[K] : never:

valueがVである要素がT[K]自身のみであれば、T[K]をそのまま返す。
もし他にも同じvalueを持つ要素がある場合は、never型になる。

そしてOption値をas constアサーションでreadonlyのリテラル型にする。
またconstで複雑な型を扱う時は、satisfaisで型推論もやると吉。

出来たオプション値

const options: SelectOption[] = defineOptions([
  { id: 4, label: 'もも' },
  { id: 6, label: 'いちご' },
  { id: 5, label: 'ナシ' },
  { id: 2, label: 'バナナ' },
  { id: 1, label: 'りんご' },
  { id: 3, label: 'ブドウ' },
  { id: 4, label: '柿' },
] as const satisfies SelectOption[]);

静的解析の結果

コードキャプチャ

TypeScript Play Ground

このようにid的な絶対に被らせたくない値についても、TSの静的解析でチェックすることができる。

Discussion