先四半期、私たちは間違った顧客に返金してしまうバグをリリースした。processRefund 関数は、order ID を期待していた箇所に user ID を受け取った。文字列は同じに見え、テストは通り、TypeScript は何も指摘しなかった。
両方の識別子は string として型付けされていた。TypeScript は両者の違いを見ない。userId を orderId を求めるパラメータに渡して、コンパイラが肩をすくめたのを見たことがあるなら、同じ壁にぶつかっている。
型エイリアスはこれを直さない。変数に注意深く名前をつけても直らない。コンパイラは、あなたが正確に指示したことをしている。それが問題なのだ。
type UserId = string が嘘である理由
TypeScript は構造的型付けを使う。2つの型は、構造が一致すれば互換性がある。type UserId = string と書いても、新しい型を作ったわけではない。エイリアスを作っただけだ。コンパイル時、UserId と string は同一である。UserId と OrderId も同様だ。
type UserId = string;
type OrderId = string;
function fetchOrder(id: OrderId) {
// ...
}
const userId: UserId = "usr_0192";
fetchOrder(userId); // Compiles. Oops.
コンパイラは型チェック中に型エイリアスを剥ぎ取る。UserId という名前は人間のためのものだ。型チェッカーは無視する。これは通常、機能の一つだ。儀式なしに実装を差し替えられる。しかし、決して交換できないはずの識別子に対しては、それが footgun なのだ。
ブランド型が同一の構造を非互換にする
修正方法は branded type であり、opaque type や newtype と呼ばれることもある。基本となる型と、型レベルにのみ存在するユニークな brand を交差させる。
実行時には値はあくまで string に過ぎない。コンパイル時には、brand がそれを他のあらゆる string から区別する。
unique symbol を使ったパターンは次の通り:
declare const UserIdBrand: unique symbol;
type UserId = string & { readonly [UserIdBrand]: void };
declare const OrderIdBrand: unique symbol;
type OrderId = string & { readonly [OrderIdBrand]: void };
unique symbol は、他の型が偶然この brand を共有できないことを保証する。readonly プロパティは、mutate で消すことができないことを意味する。実行時には symbol プロパティは実際の string に存在しないので、これは純粋にコンパイル時の構造物だ。
値を作るには、生の string をキャストするコンストラクタ関数が必要だ:
function UserId(value: string): UserId {
return value as UserId;
}
function OrderId(value: string): OrderId {
return value as OrderId;
}
これで先ほどのバグは、コードを実行する前に検出される:
const uid = UserId("usr_0192");
fetchOrder(uid);
// ^^^
// Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.
エラーメッセージは明確だ。型は brand が異なるため構造的に異なる。コンパイラは混同させない。
ボイラープレートは面倒だ。ここにヘルパーを用意した。
すべての ID 型に brand とコンストラクタを書くのはすぐに古くなる。私たちは両方を生成する小さなユーティリティを使う:
interface Brand<T> {
readonly __brand: T;
}
type Branded<T, B> = T & Brand<B>;
function makeBrand<T, B>(
_brand: B
): (value: T) => Branded<T, B> {
return (value) => value as Branded<T, B>;
}
使用例:
type UserId = Branded<string, "UserId">;
const UserId = makeBrand<string, "UserId">("UserId");
type OrderId = Branded<string, "OrderId">;
const OrderId = makeBrand<string, "OrderId">("OrderId");
これで宣言は識別子あたり2行に抑えられる。より強い衝突耐性が必要なら、ジェネリックヘルパー内で unique symbol アプローチを使うこともできる。どちらにせよ、目標は同じだ:新しい branded type を追加するコストを、実際にやるには十分低くすること。
オブジェクトや数値はどうか?
Branded types は string に限らず、あらゆるプリミティブに有効だ。数値 ID も同じ方法で brand する:
type DbUserId = Branded<number, "DbUserId">;
const DbUserId = makeBrand<number, "DbUserId">("DbUserId");
オブジェクトも brand できる。同じ shape を持つが交換してはいけない2つの設定があるなら、brand すればよい:
type ApiConfig = Branded<{
endpoint: string;
timeout: number;
}, "ApiConfig">;
ただし、オブジェクトの brand には注意が必要だ。実行時のオブジェクトには brand プロパティが存在しないため、JSON.stringify や spread 操作は普通に動作する。それが通常は望ましい動作だ。しかし、deep equality check を行う場合や、実行時に型を検査するライブラリに値を渡す場合、brand は手助けにならない。これは厳密にコンパイル時のガードなのだ。
トレードオフは実在するが、小さい
Branded types は摩擦を加える。すべての ID がコンストラクタ呼び出しを必要とするようになる。生の string を branded type を期待する関数にインラインで渡すことはできない。それが目的だが、より多くのコードを意味する。
シリアライゼーションはもう一つの落とし穴だ。branded string を JSON.stringify すると、生の string が戻ってくる。後で parse すると、brand は失われる。システム境界でコンストラクタを再適用する必要がある。私たちは通常、API レスポンスパーサーやデータベース row mapper でこれを行う。
const raw = await db.query("SELECT id FROM users WHERE ...");
return raw.map((row) => UserId(row.id));
Branded types は実行時のバリデーションも手助けしない。string が不正な形式であっても、brand は検出しない。システムの境界では依然として zod、valibot、あるいは手動のバリデーションが必要だ。brand が保証するのは型の区別であり、データの正しさではない。
これが価値を持つときと、ノイズに過ぎないとき
私たちはモジュール境界を超える識別子を brand する:user ID、organization ID、trace ID、span ID などだ。これらは最も多くのコードを通過し、順序を間違えて渡される可能性が最も高い値だ。
内部的なループカウンターやローカルの一時変数、関数より小さいスコープのものには brand をつけない。生まれた場所を離れない値に対しては、オーバーヘッドに見合わない。
コードベースに3つ連続した string パラメータを持つ関数シグネチャがあるなら、それは強い信号だ。branded types はその呼び出し箇所を推測ゲームから、コンパイラが代わりにチェックしてくれるものに変える。
実践的な始め方
明日までにコードベースのすべての string に brand をつける必要はない。実際にバグを引き起こした識別子を選ぶ。それらに brand を追加する。次のリファクタリングでコンパイラが混同を検出するのを見届けよう。ビルドエラーが本番バグを防いだその一瞬が、追加のコンストラクタ呼び出しが安く感じられる瞬間だ。
1つのモジュール境界から始めよう。UserId brand を追加する。OrderId brand を追加する。row が入ってくるときにコンストラクタを適用するよう、データベースレイヤーを更新する。感覚を確かめてみよう。コードレビューで1つ間違った引数を検出できれば、元は取れたことになる。