TypeScriptの「型」でドメインを封じ込める:Branded Typesによる堅牢な設計術
Web開発の現場で、私たちは日々「プリミティブな型」の暴力に晒されています。
`string`、`number`、`boolean`。これらは便利ですが、ドメインの文脈を無視します。`UserId`も`ProductId`も`Email`も、すべて単なる`string`として扱われていないでしょうか?その結果、IDを入れ替えて関数に渡してもコンパイラは沈黙し、本番環境で予期せぬデータ破壊が起きる。
これは「型システム」を使っているようで、実際には「型」の力をドメイン層で放棄しているに等しい。
今回は、TypeScriptの型システムをハックし、コンパイル時にドメインの整合性を強制する「ブランド型(Branded Types)」の極致を伝授します。
—
1. なぜ「ただのstring」では不十分なのか
以下のような関数を想像してください。
function deleteUser(userId: string) { / … / }
function deleteProduct(productId: string) { / … / }
const productId = “PROD_123”;
deleteUser(productId); // コンパイルエラーにならない!
TypeScriptは構造的部分型(Structural Subtyping)を採用しています。`string`はどこまで行っても`string`であり、意味論的な区別をコンパイラは理解しません。これを放置するのは、「型安全という防具を捨てて戦場に出る」のと同じです。
2. Branded Types:型に「意味」という烙印を押す
解決策は、型に「ブランド」を付与することです。これにより、コンパイラは「中身は文字列だが、これは特定の文脈の文字列である」と認識するようになります。
実装パターン
/
- ブランド型を生成するためのユーティリティ
- __brandという「存在しないプロパティ」を付与することで、
- 構造的部分型の判定を回避する
/
type Brand
type UserId = Brand
type Email = Brand
// コンストラクタ関数(ランタイムでの検証を忘れずに)
const toUserId = (id: string): UserId => id as UserId;
const toEmail = (id: string): Email => {
if (!id.includes(“@”)) throw new Error(“Invalid Email”);
return id as Email;
};
function sendNotification(email: Email, userId: UserId) {
console.log(`Sending to ${email} for user ${userId}`);
}
const myId = toUserId(“user_001”);
const myEmail = toEmail(“test@example.com”);
sendNotification(myEmail, myId); // OK
// sendNotification(myId, myEmail); // コンパイルエラー!
なぜこれで上手くいくのか
TypeScriptのコンパイラは、`{ __brand: “UserId” }` を持つ型と、そうでない型を比較する際、このプロパティの有無を厳密にチェックします。ランタイム時にはこのプロパティは存在しない(消去される)ため、パフォーマンスへの影響はゼロです。これがTypeScriptの真骨頂です。
—
3. 実践:ドメイン層での活用と保守性
大規模開発では、単なる型定義だけでなく、ドメイン層での境界線(Boundary)を意識することが重要です。
現場で使うための「型安全なID生成器」
// ID生成の責務をここに集中させる
export const createId =
return `${prefix}_${Math.random().toString(36).slice(2)}` as Brand
};
// 使用例
type OrderId = Brand
const orderId = createId<"OrderId">(“ORD”); // 型安全に生成
パフォーマンスと注意点
- ランタイムコスト: ゼロです。型情報はコンパイル時にすべて消去されます。
- シリアライズ: APIのJSONレスポンスを扱う際は注意が必要です。外部から受け取ったデータは`any`や`unknown`として受け取り、必ずドメイン層の境界で`toUserId()`のような関数を通してください。ここをサボると、ブランド型はただの「お飾り」になります。
—
4. チーフアーキテクトからの助言
Branded Typesを導入すると、最初は「キャスト(`as`)が面倒だ」と感じるかもしれません。しかし、その「書くのが面倒な箇所」こそが、本来バグが紛れ込んでいた場所なのです。
1. 境界で守る: 外部APIからの入力、DBからの取得。この「境界」を越える場所でのみ、`as`を使った変換を許可する。
2. ロジックには持ち込まない: ビジネスロジック内部では、常にブランド型のみを使用する。
3. チームへの教育: 「なぜここで `as` を使うのか?」を問われたとき、「これはドメインへの入り口だからだ」と即答できるチームを作りましょう。
型は「制約」であると同時に、ドメインを記述するための「言語」です。TypeScriptの型システムを使いこなし、コンパイラを最強のレビューアに変えてください。
あなたの書くコードが、コンパイルを通るだけで正しさを保証する――。それこそが、エンジニアが追求すべき「美学」です。