T-CREATOR

<div />

TypeScriptでBrand Typesを設計に使う 値の取り違えを型で防ぐ実践

2026年1月17日
TypeScriptでBrand Typesを設計に使う 値の取り違えを型で防ぐ実践

TypeScript で開発していて「userIdproductId を間違えて渡してしまった」「金額と数量を取り違えてバグが発生した」という経験はありませんか。静的型付けを活用しているはずなのに、なぜこのような問題が起こるのでしょうか。この記事では、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);

この例では、UserIdProductId はどちらも 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 の採用を見送りました。

  1. 一時的なスクリプト: 使い捨てのデータ移行スクリプトでは、型定義のコストが見合わない
  2. 外部ライブラリとの境界: 既存ライブラリが string を期待する場合、変換コストが高い
  3. チームの習熟度が低い場合: 導入初期は混乱を招く可能性がある

実装パターンと動作確認済みコード例

具体的な実装パターンを、動作確認済みのコードとともに紹介します。

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 スキーマの定義と検証を統合したい

段階的導入とチーム運用

大規模プロジェクトでの導入戦略と、チームでの運用方法を説明します。

段階的導入のアプローチ

検証の結果、以下の順序で導入するのが効果的でした。

  1. 最も事故が起きやすい箇所から着手: ユーザー ID と注文 ID など、混同リスクが高い ID から導入
  2. 新規コードから適用: 既存コードの変更は最小限に抑え、新規開発部分から導入
  3. 移行用のアダプター関数を用意: 既存の 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 の取り違えによる事故リスクが高い箇所や、長期的に保守が必要なドメインロジックから優先的に導入することで、効果を最大化できるでしょう。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;