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?

`as const satisfies Record<Enum, V>` でマッピング漏れをビルド時に検出する

0
Posted at

TypeScript で UI を実装していると、API から返ってきた値を、表示用のラベルや i18n key に変換することがあります。

たとえば、監査ログの operation 種別を考えます。

type AuditOperation =
  | 'edit'
  | 'publish'
  | 'bulk_approve'
  | 'bulk_reject';

この値を画面にそのまま表示すると、次のような文字列がユーザーに見えてしまいます。

bulk_approve
bulk_reject

これは内部的なキーとしては問題ありませんが、UI の表示としては適切ではありません。

そのため、UI 側では operation の値を表示用のキーに変換します。

const OPERATION_KEY_MAP = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
};

そして、次のように row.operation から表示用のキーを取り出します。

const labelKey = OPERATION_KEY_MAP[row.operation];

このようなマッピング表は、多くの UI 実装で登場します。

問題は、あとから AuditOperation に新しい値を追加したときです。

type AuditOperation =
  | 'edit'
  | 'publish'
  | 'bulk_approve'
  | 'bulk_reject'
  | 'revert';

AuditOperationrevert を追加したにもかかわらず、OPERATION_KEY_MAPrevert の対応を追加し忘れると、表示用のキーを取得できません。

const OPERATION_KEY_MAP = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
};

この状態で row.operationrevert が入ると、OPERATION_KEY_MAP[row.operation]undefined になります。

その結果、次のようなフォールバックがある場合は、revert という生の文字列がそのまま表示されます。

const label = OPERATION_KEY_MAP[row.operation] ?? row.operation;

このフォールバックは、UI を壊さないという意味では便利です。

しかし、開発者が OPERATION_KEY_MAP の更新漏れに気づきにくくなる、という問題もあります。
本来は operations.revert のような表示用のキーを追加すべきなのに、画面上では revert がそのまま表示されるだけで済んでしまうためです。

この種のミスは、実行時に画面を見て気づくのではなく、ビルド時に TypeScript のエラーとして検出したいところです。

そこで使えるのが、次の書き方です。

const OPERATION_KEY_MAP = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
  revert: 'operations.revert',
} as const satisfies Record<AuditOperation, string>;

この記事では、この as const satisfies Record<Union, V> という書き方を、表示用マッピング表の漏れを防ぐためのパターンとして整理します。ここでいう union 型とは、'edit' | 'publish' のように、取りうる値を列挙した型のことです。

Record<AuditOperation, string> は必要なキーを定義する

まず、後半の Record<AuditOperation, string> から確認します。

Record<AuditOperation, string>

これは、次のようなオブジェクト型を表します。

AuditOperation のすべての値をキーに持つ
それぞれの値は string である

たとえば、AuditOperation が次の型だとします。

type AuditOperation =
  | 'edit'
  | 'publish'
  | 'bulk_approve'
  | 'bulk_reject'
  | 'revert';

このとき、Record<AuditOperation, string> は、次のキーをすべて持つオブジェクトを要求します。

edit
publish
bulk_approve
bulk_reject
revert

つまり、次のように OPERATION_KEY_MAP に対して revert を書き忘れると、TypeScript がエラーにします。

const OPERATION_KEY_MAP = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
} satisfies Record<AuditOperation, string>;
// Property 'revert' is missing

ここで重要なのは、Record<AuditOperation, string> が「必要なキーの一覧」を型として作っていることです。

AuditOperation に含まれる値が増えれば、Record<AuditOperation, string> が要求するキーも増えます。

そのため、AuditOperation に新しい値を追加したのに、OPERATION_KEY_MAP に対応するキーを追加し忘れると、ビルド時に検出できます。

satisfies はオブジェクトが条件を満たすか検査する

次に、satisfies を確認します。

const OPERATION_KEY_MAP = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
  revert: 'operations.revert',
} satisfies Record<AuditOperation, string>;

satisfies は、左側の値が右側の型を満たしているかを検査するための演算子です。

この例では、TypeScript に対して次のことを確認させています。

OPERATION_KEY_MAP は Record<AuditOperation, string> を満たしているか

言い換えると、次の条件を満たしているかを検査しています。

OPERATION_KEY_MAP には AuditOperation の全値に対応するキーがあるか
OPERATION_KEY_MAP の各値は string か

たとえば、AuditOperationrepublish_all を追加したとします。

type AuditOperation =
  | 'edit'
  | 'publish'
  | 'bulk_approve'
  | 'bulk_reject'
  | 'revert'
  | 'republish_all';

このとき、OPERATION_KEY_MAPrepublish_all の対応を追加していなければ、TypeScript がエラーにします。

const OPERATION_KEY_MAP = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
  revert: 'operations.revert',
} satisfies Record<AuditOperation, string>;
// Property 'republish_all' is missing

これにより、operation の追加と UI の表示マッピングの更新漏れを、ビルド時に結びつけられます。

as const はマッピング表を固定の定数として扱う

次に、as const を確認します。

const OPERATION_KEY_MAP = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
  revert: 'operations.revert',
} as const satisfies Record<AuditOperation, string>;

as const を付けると、このオブジェクトは「あとから書き換えるもの」ではなく、「固定の表」として扱われます。

たとえば、次のような代入はできません。

OPERATION_KEY_MAP.edit = 'operations.other';
// error

表示用のキーのマッピング表は、通常、実行中に書き換えるものではありません。

そのため、as const で固定の表として扱うのは自然です。

また、as const にはもう 1 つ重要な効果があります。

それは、値側の文字列を、単なる string ではなく、具体的な文字列として TypeScript に保持させることです。

この点は、次の節で具体例を見ます。

型注釈だけで書くと、値はただの string として扱われる

実は、キーの書き忘れを検出するだけなら、次の書き方でも可能です。

const OPERATION_KEY_MAP: Record<AuditOperation, string> = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
  revert: 'operations.revert',
};

この書き方でも、OPERATION_KEY_MAP から revert を消せば TypeScript はエラーにします。

const OPERATION_KEY_MAP: Record<AuditOperation, string> = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
};
// Property 'revert' is missing

ここで重要なのは、Record<AuditOperation, string> と型注釈すると、OPERATION_KEY_MAP の値がすべて string として扱われることです。

const OPERATION_KEY_MAP: Record<AuditOperation, string> = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
  revert: 'operations.revert',
};

const key = OPERATION_KEY_MAP.edit;

このとき、key の型は次のようになります。

const key: string

実際の値は 'operations.edit' です。

しかし、TypeScript の型としては、OPERATION_KEY_MAP.edit は単なる string です。
つまり、TypeScript は「この値は具体的に 'operations.edit' である」という情報を保持していません。

Record<AuditOperation, string> と注釈した時点で、値は string という広い型として扱われます。

as const satisfies では、値が具体的な文字列として残る

一方、as const satisfies で書くと、値側の具体的な文字列が型として残ります。

const OPERATION_KEY_MAP = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
  revert: 'operations.revert',
} as const satisfies Record<AuditOperation, string>;

const key = OPERATION_KEY_MAP.edit;

このとき、key の型は次のようになります。

const key: "operations.edit"

つまり、as const satisfies を使うと、TypeScript は OPERATION_KEY_MAP.edit の値を「ただの文字列」ではなく、「具体的に 'operations.edit' という文字列」として扱えます。

この違いは、i18n key を型で管理する場合に効いてきます。

具体的な文字列型が残ると、i18n key の型チェックにつなげられる

たとえば、利用できる i18n key を次のように型で定義しているとします。

type TranslationKey =
  | 'operations.edit'
  | 'operations.publish'
  | 'operations.bulk_approve'
  | 'operations.bulk_reject'
  | 'operations.revert';

そして、翻訳関数 t()TranslationKey だけを受け取るとします。

declare function t(key: TranslationKey): string;

このとき、Record<AuditOperation, string> と型注釈したマッピング表では、OPERATION_KEY_MAP.edit の型は string になります。

const OPERATION_KEY_MAP: Record<AuditOperation, string> = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
  revert: 'operations.revert',
};

const key = OPERATION_KEY_MAP.edit;
// const key: string

t(key);
// error
// string は TranslationKey に代入できない

実際の値は 'operations.edit' ですが、型としては string です。

そのため、TranslationKey だけを受け取る t() には渡せません。

一方、as const satisfies で書くと、OPERATION_KEY_MAP.edit の型は 'operations.edit' になります。

const OPERATION_KEY_MAP = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
  revert: 'operations.revert',
} as const satisfies Record<AuditOperation, TranslationKey>;

const key = OPERATION_KEY_MAP.edit;
// const key: "operations.edit"

t(key);
// OK

この例では、OPERATION_KEY_MAP.edit が具体的に 'operations.edit' という型として残っているため、TranslationKey として扱えます。

つまり、as const satisfies は次の 2 つを両立できます。

AuditOperation の全値に対応するキーがあるかを検査する
OPERATION_KEY_MAP の値を具体的な i18n key として扱う

これが、Record<AuditOperation, string> の型注釈だけで書く場合との実用上の違いです。

i18n key の書き間違いも検出できる

as const satisfies は、値側の書き間違いを検出する用途にも使えます。

先ほどの TranslationKey をもう一度使います。

type TranslationKey =
  | 'operations.edit'
  | 'operations.publish'
  | 'operations.bulk_approve'
  | 'operations.bulk_reject'
  | 'operations.revert';

この型を使って、OPERATION_KEY_MAP の値も検査します。

const OPERATION_KEY_MAP = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
  revert: 'operations.revret',
} as const satisfies Record<AuditOperation, TranslationKey>;

ここでは、revert に対応する i18n key を operations.revret と書き間違えています。

operations.revretTranslationKey に含まれていないため、TypeScript がエラーにします。

Type '"operations.revret"' is not assignable to type 'TranslationKey'

このように書くと、次の 2 種類のミスを同時に検出できます。

AuditOperation にあるキーを OPERATION_KEY_MAP に書き忘れている
OPERATION_KEY_MAP の値に存在しない i18n key を書いている

つまり、OPERATION_KEY_MAP のキー側と値側の両方を型で守れます。

余計なキーも検出できる

OPERATION_KEY_MAP に、AuditOperation に存在しない余計なキーを書いた場合も検出できます。

const OPERATION_KEY_MAP = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
  revert: 'operations.revert',
  delete: 'operations.delete',
} as const satisfies Record<AuditOperation, TranslationKey>;

もし deleteAuditOperation に含まれていなければ、TypeScript はエラーにします。

Object literal may only specify known properties

これにより、OPERATION_KEY_MAPAuditOperation と一致しているかを確認できます。

不足しているキーも、余計なキーも、どちらも検出できます。

as const satisfies Record<Union, V> の役割を整理する

ここまでの内容を整理します。

const OPERATION_KEY_MAP = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
  revert: 'operations.revert',
} as const satisfies Record<AuditOperation, TranslationKey>;

この書き方には、3 つの要素があります。

Record<AuditOperation, TranslationKey>

これは、必要なキーと値の型を定義します。

AuditOperation のすべての値をキーとして要求する
各値は TranslationKey であることを要求する

次に、satisfies です。

satisfies Record<AuditOperation, TranslationKey>

これは、OPERATION_KEY_MAP がその条件を満たしているかを検査します。

最後に、as const です。

as const

これは、OPERATION_KEY_MAP を固定の表として扱います。
また、operations.edit のような値を、単なる string ではなく具体的な文字列として残します。

この 3 つを組み合わせることで、次の性質を持つマッピング表を作れます。

AuditOperation の全値に対応している
AuditOperation に存在しない余計なキーを持たない
値は TranslationKey として正しい
値の具体的な文字列が型として残る

使いどころ

as const satisfies Record<Union, V> は、列挙的な union 型の値を別の値に変換する表に向いています。

たとえば、ステータスから表示ラベルへ変換する場合です。

type Status = 'draft' | 'published' | 'archived';

const STATUS_LABELS = {
  draft: '下書き',
  published: '公開済み',
  archived: 'アーカイブ済み',
} as const satisfies Record<Status, string>;

ステータスから色へ変換する場合です。

type Status = 'draft' | 'published' | 'archived';

type Color = 'gray' | 'green' | 'red';

const STATUS_COLORS = {
  draft: 'gray',
  published: 'green',
  archived: 'red',
} as const satisfies Record<Status, Color>;

ステータスから並び順へ変換する場合です。

type Status = 'draft' | 'published' | 'archived';

const STATUS_SORT_ORDER = {
  draft: 1,
  published: 2,
  archived: 3,
} as const satisfies Record<Status, number>;

いずれも考え方は同じです。

Status の全値に対して、対応する値を必ず用意する

この対応関係を TypeScript に検査させます。

値を追加したときに対応表の更新漏れがあれば、実行時ではなくビルド時に検出できます。

実行時フォールバックに頼りすぎない

マッピング漏れに備えて、次のようなフォールバックを書くことがあります。

const label = STATUS_LABELS[status] ?? status;

このフォールバックは、UI を壊さないという意味では便利です。

しかし、開発中の対応漏れも隠してしまいます。

たとえば、Statusarchived を追加したのに、STATUS_LABELSarchived の表示ラベルを追加し忘れたとします。

その場合でも、フォールバックがあると、画面には archived という生の文字列が表示されるだけで済んでしまいます。

これは「壊れにくい」一方で、「ミスに気づきにくい」実装でもあります。

既知の union 型の値に対する表示マッピングでは、実行時フォールバックに頼るより、ビルド時に対応漏れを検出する方が安全です。

const label = STATUS_LABELS[status];

この形で安全に書けるようにするために、as const satisfies Record<Union, V> を使います。

まとめ

as const satisfies Record<Union, V> は、列挙的な union 型の値を、表示ラベル、i18n key、色、並び順などに変換する表で便利です。

const MAP = {
  a: '...',
  b: '...',
  c: '...',
} as const satisfies Record<Union, V>;

この形にすると、次のミスをビルド時に検出できます。

Union 型に値を追加したのに、MAP に対応するキーを追加していない
Union 型に存在しない余計なキーを MAP に書いている
MAP の値に許可されていない値を書いている

Record<Union, V> の型注釈だけでも、キーの書き忘れは検出できます。

しかし、as const satisfies を使うと、マッピング表の値が具体的な文字列として残ります。

たとえば、次のような違いがあります。

const MAP: Record<AuditOperation, string> = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
  revert: 'operations.revert',
};

const key = MAP.edit;
// const key: string
const MAP = {
  edit: 'operations.edit',
  publish: 'operations.publish',
  bulk_approve: 'operations.bulk_approve',
  bulk_reject: 'operations.bulk_reject',
  revert: 'operations.revert',
} as const satisfies Record<AuditOperation, string>;

const key = MAP.edit;
// const key: "operations.edit"

この違いにより、i18n key の型チェックなど、次の型チェックにつなげやすくなります。

まずは、次のように理解すると扱いやすいです。

Record<Union, V>
  必要なキーと値の型を決める

satisfies
  その条件を満たしているか検査する

as const
  固定の表として扱い、値の具体的な型を残す

UI の表示マッピングでは、「値が増えたのに対応表を更新していない」というミスが起きやすくなります。

as const satisfies Record<Union, V> を使うと、そのミスを実行時ではなくビルド時に検出できます。

次の記事では、このパターンを pnpm monorepo に広げて、バックエンドと UI で enum 定義がずれる問題をどう防ぐかを扱います。

参考リンク

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?