前回の記事では、as const satisfies Record<Union, V> を使って、表示用マッピング表の対応漏れをビルド時に検出する方法を扱いました。今回の記事では、その網羅検査が正しく働くための前提を扱います。それは、バックエンドと UI が同じ値の集合を別々に定義している場合です。表示用マッピングをどれだけ丁寧に書いていても、UI 側の union 型そのものが古ければ、TypeScript は漏れを検出できません。
この記事では、pnpm monorepo でバックエンドと UI の enum drift を防ぐために、次の構成を整理します。
- enum 的な union 型を
packages/shared-typesに集約する - バックエンドと UI が同じ型を import する
- UI 側の表示マッピングは
as const satisfies Record<Union, V>で網羅検査する - shared package の参照方法を monorepo 内で決める
問題
監査ログの operation 種別を考えます。
バックエンド側では、監査ログに出力する operation を union 型として定義していました。
// services/api/src/audit/audit.ts
export type AuditOperation =
| 'edit'
| 'publish'
| 'bulk_approve'
| 'bulk_reject'
| 'bulk_disable_by_rule_id'
| 'republish_all'
| 'revert'
| 'bulk_revert';
一方、UI 側にも似た型が別ファイルで定義されていました。
// apps/dashboard/src/lib/api/audit.ts
export interface AuditLogEntry {
operation:
| 'edit'
| 'publish'
| 'bulk_approve'
| 'bulk_reject'
| 'bulk_disable_by_rule_id';
// ...
}
バックエンド側には、次の 3 つが追加されています。
'republish_all'
'revert'
'bulk_revert'
しかし、UI 側の AuditLogEntry['operation'] には、この 3 つがありません。
この時点で、バックエンドと UI の型定義がずれています。
古いまま残る UI 側のマッピング
UI では、operation を表示用キーに変換していました。
const OPERATION_KEY_MAP = {
edit: 'operations.edit',
publish: 'operations.publish',
bulk_approve: 'operations.bulk_approve',
bulk_reject: 'operations.bulk_reject',
bulk_disable_by_rule_id: 'operations.bulk_disable_by_rule_id',
};
描画側では、次のように使います。
const label = OPERATION_KEY_MAP[row.operation] ?? row.operation;
row.operation が edit や publish なら問題ありません。しかし、API から republish_all が返ってきた場合、OPERATION_KEY_MAP には対応するキーがありません。
その結果、OPERATION_KEY_MAP[row.operation] は undefined になり、フォールバックで republish_all という生の文字列が表示されます。
republish_all
これはユーザーに見せる表示としては避けたい状態です。
本来であれば、次のような表示用キーに変換されるべきです。
operations.republish_all
なぜ TypeScript が検出できなかったのか
ここで重要なのは、UI 側の型定義が古いままだったことです。
UI 側の AuditLogEntry['operation'] は、次の値しか知らない状態でした。
type UiAuditOperation =
| 'edit'
| 'publish'
| 'bulk_approve'
| 'bulk_reject'
| 'bulk_disable_by_rule_id';
この型をもとに OPERATION_KEY_MAP を作ると、TypeScript からはマッピングが網羅されているように見えます。
const OPERATION_KEY_MAP = {
edit: 'operations.edit',
publish: 'operations.publish',
bulk_approve: 'operations.bulk_approve',
bulk_reject: 'operations.bulk_reject',
bulk_disable_by_rule_id: 'operations.bulk_disable_by_rule_id',
} as const satisfies Record<UiAuditOperation, string>;
UiAuditOperation に republish_all が含まれていないため、TypeScript は republish_all の対応漏れを検出できません。
つまり、問題は satisfies の使い方ではありません。前回の記事で見た網羅検査も、前提となる Union 型の正しさを条件にしています。
UI 側の union 型が古ければ、TypeScript は古い型定義を前提にしかチェックできません。
バックエンドの実際の値
edit
publish
bulk_approve
bulk_reject
bulk_disable_by_rule_id
republish_all
revert
bulk_revert
UI 側の型が知っている値
edit
publish
bulk_approve
bulk_reject
bulk_disable_by_rule_id
この差分が enum drift です。
enum drift とは何か
この記事では、enum drift を「同じ概念を表す enum 的な値が複数箇所で定義され、片方の更新がもう片方に反映されず、値の集合がずれること」という意味で使います。今回の例では、バックエンドと UI がそれぞれ別々に AuditOperation 相当の型を持っていました。そのため、バックエンド側に値を追加しても、UI 側の型は自動では更新されません。この状態では、UI 側でいくら satisfies Record<AuditOperation, string> を使っても、古い AuditOperation に対するチェックしかできません。
網羅検査を働かせるには、まず型定義そのものを共有する必要があります。
union 型の shared-types への集約
まず、AuditOperation の定義を 1 箇所に寄せます。
pnpm monorepo なら、共有 package を作れます。
packages/
shared-types/
src/
audit.ts
index.ts
services/
api/
apps/
dashboard/
packages/shared-types/src/audit.ts に、監査ログで使う型を定義します。
// packages/shared-types/src/audit.ts
export type AuditOperation =
| 'edit'
| 'publish'
| 'bulk_approve'
| 'bulk_reject'
| 'bulk_disable_by_rule_id'
| 'republish_all'
| 'revert'
| 'bulk_revert';
export interface AuditTarget {
rule_id?: string;
page_url?: string;
}
index.ts から export しておきます。
// packages/shared-types/src/index.ts
export type { AuditOperation, AuditTarget } from './audit';
これで、AuditOperation の定義元は packages/shared-types になります。
バックエンドからの shared-types の import
バックエンド側では、独自に AuditOperation を定義しません。
@siteforce/shared-types から import するようにします。
// services/api/src/audit/audit.ts
import type {
AuditOperation,
AuditTarget,
} from '@siteforce/shared-types';
export type { AuditOperation, AuditTarget };
このようにしておくと、バックエンド側で監査ログを作るときも、shared-types の AuditOperation を使えます。
function createAuditLog(operation: AuditOperation, target: AuditTarget) {
// ...
}
バックエンドが新しい operation を追加する場合は、packages/shared-types の AuditOperation を更新します。
UI からの shared-types の import
UI 側も、独自の union 型を持たないようにします。
// apps/dashboard/src/lib/api/audit.ts
import type {
AuditOperation,
AuditTarget,
} from '@siteforce/shared-types';
export type { AuditOperation, AuditTarget };
export interface AuditLogEntry {
operation: AuditOperation;
target: AuditTarget & Record<string, unknown>;
}
これで、UI 側の AuditLogEntry['operation'] も、shared-types の AuditOperation を見るようになります。バックエンドが AuditOperation に値を追加した場合、UI 側の型も同じ値を認識します。
つまり、次の状態を作れます。
バックエンドが使う AuditOperation
packages/shared-types の AuditOperation
UI が使う AuditOperation
packages/shared-types の AuditOperation
同じ型を参照するため、バックエンドと UI が別々の値集合を持つ状態を避けられます。
UI 側の表示マッピングの網羅検査
型定義を共有しただけでは、UI 側の表示マッピング更新漏れはまだ起こりえます。
そこで、前回の記事で扱った as const satisfies Record<Union, V> を使います。
import type { AuditOperation } from '@siteforce/shared-types';
type TranslationKey =
| 'operations.edit'
| 'operations.publish'
| 'operations.bulk_approve'
| 'operations.bulk_reject'
| 'operations.bulk_disable_by_rule_id'
| 'operations.republish_all'
| 'operations.revert'
| 'operations.bulk_revert';
const OPERATION_KEY_MAP = {
edit: 'operations.edit',
publish: 'operations.publish',
bulk_approve: 'operations.bulk_approve',
bulk_reject: 'operations.bulk_reject',
bulk_disable_by_rule_id: 'operations.bulk_disable_by_rule_id',
republish_all: 'operations.republish_all',
revert: 'operations.revert',
bulk_revert: 'operations.bulk_revert',
} as const satisfies Record<AuditOperation, TranslationKey>;
これで、AuditOperation に含まれるすべての値に対して、表示用キーを用意する必要があります。
たとえば、あとから restore を追加したとします。
export type AuditOperation =
| 'edit'
| 'publish'
| 'bulk_approve'
| 'bulk_reject'
| 'bulk_disable_by_rule_id'
| 'republish_all'
| 'revert'
| 'bulk_revert'
| 'restore';
このとき、OPERATION_KEY_MAP に restore の対応がなければ TypeScript がエラーにします。
Property 'restore' is missing
この構成により、次の流れを作れます。
AuditOperation に値を追加する
↓
UI 側の OPERATION_KEY_MAP に対応がない
↓
型チェックが失敗する
↓
表示マッピングの更新漏れに気づく
実行時フォールバックに頼りすぎない
以前の実装では、マッピングにない値をそのまま表示するフォールバックがありました。
const label = OPERATION_KEY_MAP[row.operation] ?? row.operation;
このフォールバックは、UI を壊さないという意味では便利です。しかし、既知の operation に対する表示漏れも隠してしまいます。
shared-types と satisfies で既知の operation を網羅できているなら、通常は次のように書けます。
const labelKey = OPERATION_KEY_MAP[row.operation];
もちろん API 境界には、未知の値が来ます。たとえば、古い UI と新しいバックエンドが一時的に混在するデプロイ中や、外部 API から値を受け取る場合です。
その場合は、既知の AuditOperation と未知の値を分けて扱う方が安全です。
type AuditLogEntry =
| {
operation: AuditOperation;
target: AuditTarget & Record<string, unknown>;
}
| {
operation: 'unknown';
rawOperation: string;
target: AuditTarget & Record<string, unknown>;
};
または、API レスポンスを受け取る時点で実行時に検証し、未知の operation を明示的に unknown として扱います。
重要なのは、既知の operation に対する対応漏れを、実行時フォールバックで曖昧にしないことです。
既知の値に対するマッピング漏れは、ビルド時に検出します。
未知の値への耐性は、別の設計として扱います。
pnpm monorepo で shared-types をどう参照するか
packages/shared-types に型を集約すると、次に考えることがあります。それは、他の package が @siteforce/shared-types をどう解決するかです。
たとえば、packages/shared-types/package.json が次のようになっているとします。
{
"name": "@siteforce/shared-types",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
}
この package.json の構成で、利用側 package は dist/index.d.ts を見ます。そのため shared-types を build していない状態だと、dist/index.d.ts が存在せず型チェックに失敗することがあります。ここは monorepo の運用として決める必要があります。
主な選択肢は 3 つです。
shared-types を先に build する方法
一番分かりやすいのは、利用側 package の型チェック前に shared-types を build する方法です。
{
"scripts": {
"typecheck": "pnpm --filter @siteforce/shared-types build && tsc --noEmit"
}
}
pnpm の --filter を使うと、対象 package を絞ってコマンドを実行できます。この方法は、既存の script に追加しやすいのが利点です。ただし、package が増えてくると、各 script に依存順序が散らばりやすくなります。
小さな monorepo なら十分です。長期的には、次の Project References のほうが扱いやすくなります。
選択肢 2: TypeScript Project References を使う
TypeScript Project References を使うと、TypeScript に package 間の依存関係を伝えられます。
利用側 package の tsconfig.json に、shared-types への参照を書きます。
{
"references": [
{ "path": "../../packages/shared-types" }
]
}
そのうえで、tsc -b を使います。
tsc -b
tsc -b は、参照関係を見ながら必要な順番で build します。shared-types を package として build し、dist を正として扱うなら、この方法が自然です。ただし、Project References を使うには、各 package の tsconfig.json を少し整える必要があります。
たとえば、参照される側では composite: true や declaration: true が必要になります。
選択肢 3: paths alias で src を直接見る
社内 monorepo に閉じているなら、paths alias で shared-types の src を直接見る方法もあります。
{
"compilerOptions": {
"paths": {
"@siteforce/shared-types": [
"../../packages/shared-types/src/index.ts"
]
}
}
}
この方法では、dist を作らなくても型チェックできます。開発時の手数は少なくなります。
一方で、package として publish する場合は、開発時の paths と package 公開時の exports / types が別管理になります。
開発時
@siteforce/shared-types → src/index.ts
package として利用するとき
@siteforce/shared-types → dist/index.d.ts
社内 monorepo の中だけで使うなら、この割り切りでも問題ないことがあります。
外部 publish や package 境界を重視するなら、Project References の方が整合しやすいです。
どの方法を選ぶか
大まかな判断基準は次のようになります。
社内 monorepo に閉じていて、shared-types を publish しないなら、paths alias が導入しやすい方法です。
@siteforce/shared-types → packages/shared-types/src/index.ts
一方で、shared-types を package として build / publish するなら、Project References を使って dist を正にする方が自然です。
shared-types を build
↓
dist/index.d.ts を生成
↓
UI / バックエンドがそれを参照
小さく始めるなら paths alias。
package 境界を明確に扱うなら Project References。
このくらいの基準で選ぶと分かりやすいです。
まとめ
バックエンドと UI が同じ enum 的な値を別々に定義していると、値の追加時に drift が起きます。今回の例を見ます。バックエンド側の AuditOperation へ republish_all、revert、bulk_revert が追加されていました。一方で UI 側の AuditLogEntry['operation'] と表示マッピングが古いままでした。この状態では、TypeScript は UI 側の古い型をもとにチェックするため、実際の API から来る新しい値を検出できません。
対策は、次の組み合わせです。
AuditOperation を packages/shared-types に集約する
バックエンドと UI の両方が同じ AuditOperation を import する
UI 側の表示マッピングを as const satisfies Record<AuditOperation, TranslationKey> で網羅検査する
この構成なら、AuditOperation へ値を追加したとき、UI 側の表示マッピングが未対応であれば型チェックに失敗します。バックエンドへ値を追加したのに UI が追従していない、という問題をビルド時に検出できます。前回の記事で扱った as const satisfies Record<Union, V> は、1 つの union 型に対するマッピング漏れを防ぐためのパターンでした。
今回の記事のポイントは、その union 型自体をバックエンドと UI で共有することです。
型を共有する
↓
共有された型に対して表示マッピングを網羅検査する
この 2 段構えにしておくと、monorepo 内の enum drift を実行時ではなく型チェックの段階で検出できます。