【TypeScript】文字列の混同という人災を防ぐ。ブランド型(Branded Types)によるドメイン駆動型型設計の極意
コードレビューをしていて、次のようなコードにゾッとしたことはないだろうか。
function transfer(fromAccountId: string, toAccountId: string, amount: number): void {
// …
}
// 呼び出し側
transfer(toAmount, fromAccountId, 1000); // 💡 引数の順序を間違えているが、コンパイラは何も言わない
`fromAccountId` と `toAccountId` はどちらも `string` 型だ。TypeScriptの構造的型システム(Structural Subtyping)において、これらは「名前が違うだけの完全に同一の型」とみなされる。そのため、人間がうっかり引数を逆に渡したり、単なる文字列(例えば `userId` や `email`)をそのまま渡したりしても、コンパイルエラーは起きない。
この「プリミティブの乱用」は、大規模なフロントエンド開発やAPI連携において、テストでは検知しにくいサイレントなバグの温床となる。
今回は、この課題をコンパイル時のオーバーヘッドゼロで完全に解決する「ブランド型(Branded Types)」の極限的な実装パターンと、実務での実践知をテクニカルリードの視点から解説する。
—
1. ブランド型とは何か?:型システムへの「嘘」の効用
ブランド型(Nominal Typing / Tagged Types のエミュレーション)とは、TypeScriptの構造的型システムにあえて「公称型(Nominal Typing)」の制約を持ち込むテクニックだ。
基本的なアイデアはシンプルである。ベースとなるプリミティブ型に、実行時には存在しない「幽霊プロパティ(Phantom Property)」を交差型(Intersection)で付与する。
// ブランド型の基本イディオム
type Brand
この `__brand` プロパティは、実行時のメモリには一切存在しない。しかし、TypeScriptの型チェッカーは、このプロパティの存在とリテラル型の差異を厳密に区別する。 これにより、「同じ `string` だが、意味が全く異なる値」を型レベルで隔離できるのだ。
—
2. プロダクション品質のブランド型設計
単に `Brand` 型を定義するだけでは実務では不十分だ。値の生成時にバリデーション(境界づけ)を行い、信頼できないプリミティブから安全にブランド型へ昇格させるための「スマートコンストラクター(Smart Constructor)」をセットで実装する必要がある。
以下に、実務のフロントエンド・Node.js環境でそのまま使える堅牢な実装を示す。
/
- 汎用ブランド型ジェネリック
/
type Brand
// — ドメイン固有の型定義 —
export type UserId = Brand
export type AccountId = Brand
export type EmailAddress = Brand
/
- バリデーション付きスマートコンストラクターの群れ
- 境界(APIレスポンスやユーザー入力)で必ずこれらを通過させる。
/
export const UserId = {
// 外部からの入力を安全にブランド型へ変換する
parse(value: unknown): UserId {
if (typeof value !== ‘string’ || !/^usr_[a-zA-Z0-9]{16}$/.test(value)) {
throw new TypeError(`Invalid UserId format: ${value}`);
}
return value as UserId;
},
// 信頼されたハードコード値などのためのアース機能(慎重に使用すること)
asUnsafe(value: string): UserId {
return value as UserId;
}
};
export const AccountId = {
parse(value: unknown): AccountId {
if (typeof value !== ‘string’ || !value.startsWith(‘acc_’)) {
throw new TypeError(`Invalid AccountId format: ${value}`);
}
return value as AccountId;
}
};
export const EmailAddress = {
parse(value: unknown): EmailAddress {
if (typeof value !== ‘string’ || !value.includes(‘@’)) {
throw new TypeError(`Invalid EmailAddress format: ${value}`);
}
return value as EmailAddress;
}
};
この設計の美しさ
1. 実行時コストがゼロ: コンパイル後、これらのブランドは完全に消え去り、ただの `string` として実行される。V8エンジンの最適化を阻害しない。
2. 境界での防御(Boundary Defense): `unknown` を受け取り、バリデーションを通過したものだけをブランド化するため、アプリケーションの深部(ドメイン層)に汚染されたデータが流れ込むのを物理的に阻止できる。
—
3. 実務での活用シーン:APIクライアントとコンポーネント設計
では、このブランド型を実際のアプリケーションコードでどう活かすのか。銀行口座の送金処理を例に見てみよう。
// — ドメイン層の関数 —
function executeTransfer(
from: AccountId,
to: AccountId,
amount: number
): Promise
// ここに到達した時点で、from と to が AccountId であることは型保証されている
console.log(`Transfering ${amount} from ${from} to ${to}`);
return Promise.resolve();
}
// — アプリケーション層 / API境界 —
async function handleTransferRequest(rawBody: unknown) {
// 1. リクエストボディのパース (ここで初めて安全性が担保される)
const body = rawBody as { from: unknown; to: unknown; amount: unknown };
const fromAccount = AccountId.parse(body.from);
const toAccount = AccountId.parse(body.to);
const amount = Number(body.amount);
if (isNaN(amount) || amount <= 0) { throw new Error('Invalid amount'); } // 2. 処理の実行 // コンパイルエラーを防ぐための型安全な呼び出し await executeTransfer(fromAccount, toAccount, amount); // 【コンパイルエラーの例】 // うっかり順序を逆にして渡そうとすると... // await executeTransfer(toAccount, fromAccount, amount); // ❌ Error: Argument of type 'AccountId' is not assignable to parameter of type 'AccountId'. // (※実際にはプロパティ名や内部タグが異なるため、混同すると確実に弾かれる) // 【重大なヒューマンエラーの防止】 const userId = UserId.parse('usr_1234567890abcdef'); // await executeTransfer(userId, toAccount, amount); // ❌ Error: Type 'UserId' is not assignable to type 'AccountId'. } フロントエンドのコンポーネント設計においても、`UserId` を受け取るべきプロフィールカードに、誤って `AccountId` を渡すようなミスをコンパイル段階で完全に封じ込めることができる。 ---
4. パフォーマンスとコンパイルタイムの注意点
チーフアーキテクトとして、このパターンを導入する際の「トレードオフ」についても言及しておかなければならない。
1. `JSON.stringify` との相性
ブランド型は単なるプリミティブなので、そのまま `JSON.stringify` できる。しかし、サーバーから受け取ったJSONを `parse` 関数に通さずに直接ブランド型として扱うのはアンチパターンだ。必ず境界でスマートコンストラクターを通すこと。
2. ライブラリ(ZodやValibot)との統合
もしプロジェクトで `Zod` などのスキーマバリデーションライブラリを採用している場合、ブランド型は以下のようにインラインで定義できる。
import { z } from ‘zod’;
// Zodの .brand() を使うと、ランタイムのパースと型安全なブランディングを同時に達成できる
export const AccountIdSchema = z.string().startsWith(‘acc_’).brand(‘AccountId’);
export type AccountId = z.infer
// 使用例
const accountId = AccountIdSchema.parse(‘acc_987654’); // 型は AccountId
手動で `parse` 関数を書くコストが高い場合は、このようなバリデーションライブラリのブランド機能を使うのが現代的なベストプラクティスだ。
—
5. まとめ:型は「ドキュメント」ではなく「コンパイラによる防壁」である
多くのジュニア〜ミドルクラスの開発者は、TypeScriptの型を「IDEの補完を効かせるためのドキュメント」程度に捉えがちだ。しかし、真のシニアエンジニアは、型を「不正な状態の表現を不可能なものにする(Make illegal states unrepresentable)」ための厳格な防壁として使う。
文字列という最もプリミティブで危険なプリミティブを、ブランド型によってドメイン固有の強固なオブジェクトへと昇格させる。この設計手法を取り入れるだけで、コードベースの品質は劇的に跳ね上がり、「なぜか本番環境でIDの取り違えバグが起きる」という悪夢から解放されることになる。
今日のコードレビューから、あなたのプロジェクトの `string` を見直してみよう。それは本当に、ただの `string` なのだろうか?