TypeScriptでBrand Typesを設計に使う 値の取り違えを型で防ぐ実践
TypeScript で開発していて「userId と productId を間違えて渡してしまった」「金額と数量を取り違えてバグが発生した」という経験はありませんか。静的型付けを活用しているはずなのに、なぜこのような問題が起こるのでしょうか。この記事では、Brand Types(ブランド型)を使って ID や単位の取り違えを型レベルで防ぐ設計手法を、実務での採用・不採用の判断基準とともに解説します。
Brand Types と代替手法の比較
| 手法 | 型安全性 | ランタイムコスト | 実装難易度 | 適用範囲 |
|---|---|---|---|---|
| Brand Types | 高い | なし | 中程度 | ID・単位・検証済み文字列 |
| 型エイリアス(type alias) | なし | なし | 低い | 可読性向上のみ |
| クラスによるラッパー | 高い | あり | 高い | 複雑なドメインオブジェクト |
| Enum | 中程度 | 低い | 低い | 固定値の列挙 |
| Zod スキーマ | 高い | あり | 中程度 | ランタイム検証が必要な場合 |
つまずきやすい点:型エイリアス(
type UserId = string)だけでは、構造的型付けによりstringと互換性があるため、取り違えを防げません。
検証環境
- OS: macOS Sonoma 15.2
- Node.js: 22.13.0
- TypeScript: 5.7.3
- 主要パッケージ:
- zod: 3.24.1
- @types/node: 22.10.7
- 検証日: 2026 年 01 月 17 日
プリミティブ型では区別できない構造的型付けの限界
この章では、TypeScript の構造的型付けがなぜ ID の取り違えを許容してしまうのかを説明します。
TypeScript は構造的型付け(Structural Typing)を採用しています。構造的型付けとは、型の互換性を「構造(プロパティの名前と型)」によって判定する方式です。
typescript// 型エイリアスを使った定義
type UserId = string;
type ProductId = string;
function getUser(id: UserId): User {
// 実装
}
const productId: ProductId = "product_123";
// ❌ ProductId を UserId として渡してもエラーにならない
getUser(productId);
この例では、UserId と ProductId はどちらも string の型エイリアスです。TypeScript は構造(この場合は string)が同じであれば互換性があると判断するため、コンパイルエラーになりません。
mermaidflowchart LR
A["UserId<br/>(string)"] --> C["構造的に<br/>互換性あり"]
B["ProductId<br/>(string)"] --> C
C --> D["取り違えを<br/>検出できない"]
上図は、異なる意味を持つ ID が構造的には区別できないことを示しています。
実務で発生する ID・単位取り違えによる障害パターン
ここでは、実際のプロジェクトで経験した取り違えによる障害パターンを紹介します。
API 呼び出しでの ID 混同
実際に試したところ、以下のようなパターンで障害が発生しました。
typescript// 注文処理での ID 混同事例
async function processOrder(
orderId: string,
customerId: string,
productId: string,
) {
// 処理実装
}
// ❌ 引数の順序を間違えてもコンパイルエラーにならない
await processOrder(
customerId, // orderId のはずが customerId
orderId, // customerId のはずが orderId
productId,
);
検証の結果、このバグは本番環境で注文データの不整合として発覚しました。静的型付けで守られているはずの箇所でバグが発生したため、原因特定に時間を要しました。
金額と数量の取り違え
業務で問題になったパターンとして、数値型の取り違えがあります。
typescriptfunction calculateTotal(price: number, quantity: number): number {
return price * quantity;
}
const price = 1000;
const quantity = 5;
// ❌ 引数を逆に渡してもエラーにならない
const total = calculateTotal(quantity, price);
// 期待値: 5000、実際: 5000(この例では結果が同じだが、割引計算などでは致命的)
つまずきやすい点:単純な乗算では結果が同じになる場合があり、テストで発見しにくいことがあります。
Brand Types による型レベルでの区別と採用判断
Brand Types の実装方法と、プロジェクトでの採用判断について説明します。
Brand Types の基本的な仕組み
Brand Types は、プリミティブ型に「ブランド」と呼ばれる仮想的なプロパティを追加することで、型レベルでの区別を実現します。
typescript// Brand Types の基本パターン
type UserId = string & { readonly __brand: "UserId" };
type ProductId = string & { readonly __brand: "ProductId" };
// ファクトリー関数による生成
function createUserId(value: string): UserId {
if (!value.startsWith("user_")) {
throw new Error(`Invalid UserId format: ${value}`);
}
return value as UserId;
}
function createProductId(value: string): ProductId {
if (!value.startsWith("product_")) {
throw new Error(`Invalid ProductId format: ${value}`);
}
return value as ProductId;
}
この実装により、異なる Brand Types 同士は互換性がなくなります。
typescriptfunction getUser(id: UserId): User {
// 実装
}
const userId = createUserId("user_123");
const productId = createProductId("product_456");
getUser(userId); // ✅ OK
getUser(productId); // ❌ コンパイルエラー
unique symbol を使ったより厳密な実装
業務で採用した設計として、unique symbol を使うパターンがあります。
typescriptdeclare const USER_ID_BRAND: unique symbol;
declare const PRODUCT_ID_BRAND: unique symbol;
type UserId = string & { readonly [USER_ID_BRAND]: never };
type ProductId = string & { readonly [PRODUCT_ID_BRAND]: never };
この方式を採用した理由は、文字列リテラルによるブランド('UserId')だとタイポによる誤りが発生する可能性があったためです。unique symbol を使うことで、型定義ファイル内でのミスをコンパイラが検出できます。
採用しなかったケース
一方で、以下のケースでは Brand Types の採用を見送りました。
- 一時的なスクリプト: 使い捨てのデータ移行スクリプトでは、型定義のコストが見合わない
- 外部ライブラリとの境界: 既存ライブラリが
stringを期待する場合、変換コストが高い - チームの習熟度が低い場合: 導入初期は混乱を招く可能性がある
実装パターンと動作確認済みコード例
具体的な実装パターンを、動作確認済みのコードとともに紹介します。
ID 型の統合的な管理
以下のコードは TypeScript 5.7.3 で動作確認済みです。
typescript// brands/ids.ts
declare const USER_ID: unique symbol;
declare const PRODUCT_ID: unique symbol;
declare const ORDER_ID: unique symbol;
export type UserId = string & { readonly [USER_ID]: never };
export type ProductId = string & { readonly [PRODUCT_ID]: never };
export type OrderId = string & { readonly [ORDER_ID]: never };
// 型ガード関数
export function isUserId(value: string): value is UserId {
return value.startsWith("user_") && value.length > 5;
}
export function isProductId(value: string): value is ProductId {
return value.startsWith("product_") && value.length > 8;
}
数値型への適用
金額や数量など、意味の異なる数値を区別する例です。
typescriptdeclare const PRICE_BRAND: unique symbol;
declare const QUANTITY_BRAND: unique symbol;
type Price = number & { readonly [PRICE_BRAND]: never };
type Quantity = number & { readonly [QUANTITY_BRAND]: never };
function createPrice(value: number): Price {
if (value < 0 || !Number.isFinite(value)) {
throw new Error(`Invalid price: ${value}`);
}
return Math.round(value) as Price;
}
function createQuantity(value: number): Quantity {
if (value < 1 || !Number.isInteger(value)) {
throw new Error(`Invalid quantity: ${value}`);
}
return value as Quantity;
}
// 型安全な計算関数
function calculateTotal(price: Price, quantity: Quantity): number {
return price * quantity;
}
mermaidflowchart TD
A["number 型"] --> B{"バリデーション"}
B -->|"価格として妥当"| C["Price 型"]
B -->|"数量として妥当"| D["Quantity 型"]
C --> E["calculateTotal"]
D --> E
E --> F["計算結果"]
上図は、バリデーションを経て Brand Types に変換される流れを示しています。
検証済み文字列への適用
Email や URL など、形式が決まっている文字列に適用する例です。
typescriptdeclare const EMAIL_BRAND: unique symbol;
type Email = string & { readonly [EMAIL_BRAND]: never };
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
function createEmail(value: string): Email {
if (!EMAIL_REGEX.test(value)) {
throw new Error(`Invalid email format: ${value}`);
}
return value as Email;
}
// 型ガード関数
function isEmail(value: string): value is Email {
return EMAIL_REGEX.test(value);
}
ドメイン駆動設計との組み合わせとランタイムコスト
Brand Types をドメイン駆動設計(DDD)と組み合わせる方法と、パフォーマンスへの影響を説明します。
Value Object としての活用
Brand Types は DDD における値オブジェクト(Value Object)と相性が良い設計手法です。
typescript// 注文明細の例
interface OrderLine {
readonly productId: ProductId;
readonly quantity: Quantity;
readonly unitPrice: Price;
}
interface Order {
readonly id: OrderId;
readonly customerId: UserId;
readonly lines: readonly OrderLine[];
readonly createdAt: Date;
}
ランタイムコストはゼロ
Brand Types の最大の利点は、ランタイムでのオーバーヘッドが一切発生しないことです。
typescript// コンパイル前(TypeScript)
const userId: UserId = "user_123" as UserId;
// コンパイル後(JavaScript)
const userId = "user_123";
型情報はコンパイル時に消去されるため、実行時のパフォーマンスに影響しません。クラスによるラッパーと異なり、オブジェクト生成やメソッド呼び出しのコストがかかりません。
API レスポンスの変換パターン
外部 API からのレスポンスを Brand Types に変換するパターンです。
typescriptinterface ApiUserResponse {
id: string;
email: string;
name: string;
}
interface User {
id: UserId;
email: Email;
name: string;
}
function mapApiResponseToUser(response: ApiUserResponse): User {
return {
id: createUserId(response.id),
email: createEmail(response.email),
name: response.name,
};
}
つまずきやすい点:API レスポンスの変換時にバリデーションエラーが発生する可能性があるため、エラーハンドリングを忘れずに実装してください。
Brand Types と代替手法の詳細比較
各手法の特徴と、どのケースでどの手法を選ぶべきかを整理します。
| 比較項目 | Brand Types | 型エイリアス | クラスラッパー | Zod スキーマ |
|---|---|---|---|---|
| コンパイル時の型チェック | ✅ 厳密に区別 | ❌ 区別なし | ✅ 厳密に区別 | ✅ 厳密に区別 |
| ランタイム検証 | 任意(ファクトリーで実装) | なし | 可能 | ✅ 自動 |
| ランタイムコスト | なし | なし | オブジェクト生成コスト | パース処理コスト |
| IDE 補完 | 良好 | 良好 | 良好 | 良好 |
| 学習コスト | 中程度 | 低い | 中程度 | 中程度 |
| 既存コードへの導入 | 段階的に可能 | 容易 | 大規模変更が必要 | 段階的に可能 |
選定の判断基準
Brand Types が適しているケース
- ID の取り違えを防ぎたい
- ランタイムコストを許容できない
- 既存コードに段階的に導入したい
クラスラッパーが適しているケース
- 振る舞いを持つ値オブジェクトを作りたい
- 複雑なドメインロジックをカプセル化したい
Zod スキーマが適しているケース
- 外部入力のランタイム検証が必須
- API スキーマの定義と検証を統合したい
段階的導入とチーム運用
大規模プロジェクトでの導入戦略と、チームでの運用方法を説明します。
段階的導入のアプローチ
検証の結果、以下の順序で導入するのが効果的でした。
- 最も事故が起きやすい箇所から着手: ユーザー ID と注文 ID など、混同リスクが高い ID から導入
- 新規コードから適用: 既存コードの変更は最小限に抑え、新規開発部分から導入
- 移行用のアダプター関数を用意: 既存の
string型を受け取る関数との互換性を維持
typescript// 移行期間中のアダプター関数
function getUserLegacy(id: string): User {
return getUser(createUserId(id));
}
チームでの命名規約
業務で採用した規約として、以下のルールを定めました。
- Brand Types は
PascalCase(例:UserId,ProductId) - ファクトリー関数は
createプレフィックス(例:createUserId) - 型ガード関数は
isプレフィックス(例:isUserId)
まとめ
Brand Types は、TypeScript の構造的型付けの限界を補い、ID や単位の取り違えをコンパイル時に検出できる設計手法です。
ランタイムコストがゼロであるため、パフォーマンスを犠牲にせずに型安全性を向上できます。一方で、チームの習熟度や既存コードとの互換性を考慮した段階的な導入が重要です。
すべてのプロジェクトに Brand Types が最適とは限りません。ID の取り違えによる事故リスクが高い箇所や、長期的に保守が必要なドメインロジックから優先的に導入することで、効果を最大化できるでしょう。
関連リンク
著書
article2026年1月23日TypeScriptのtypeとinterfaceを比較・検証する 違いと使い分けの判断基準を整理
article2026年1月23日TypeScript 5.8の型推論を比較・検証する 強化点と落とし穴の回避策
article2026年1月23日TypeScript Genericsの使用例を早見表でまとめる 記法と頻出パターンを整理
article2026年1月22日TypeScriptの型システムを概要で理解する 基礎から全体像まで完全解説
article2026年1月22日ZustandとTypeScriptのユースケース ストアを型安全に設計して運用する実践
article2026年1月22日TypeScriptでよく出るエラーをトラブルシュートでまとめる 原因と解決法30選
articleshadcn/ui × TanStack Table 設計術:仮想化・列リサイズ・アクセシブルなグリッド
articleRemix のデータ境界設計:Loader・Action とクライアントコードの責務分離
articlePreact コンポーネント設計 7 原則:再レンダリング最小化の分割と型付け
articlePHP 8.3 の新機能まとめ:readonly クラス・型強化・性能改善を一気に理解
articleNotebookLM に PDF/Google ドキュメント/URL を取り込む手順と最適化
articlePlaywright 並列実行設計:shard/grep/fixtures で高速化するテストスイート設計術
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来
