Featured image of post TypeScript Branded Types:プリミティブ型の混同を防ぐ Featured image of post TypeScript Branded Types:プリミティブ型の混同を防ぐ

TypeScript Branded Types:プリミティブ型の混同を防ぐ

TypeScriptのBranded Typesによる名目型付けシミュレーションを深掘り。ブランドパターン、交差型、バリデーション関数、ID・通貨・メールのユースケースまで解説。

TypeScriptは構造的型付け(ダックタイピング)を採用しており、同じ形状を持つ型は互換性があります。これにより、プリミティブ型でドメイン概念を表現する際にバグが発生します。例えば、userIdを渡すべき場所にorderIdを渡しても、両方ともstring型であるためコンパイラは検出できません。Branded Typesは、コンパイル時に構造的に同一の型を区別するファントム型マーカーを追加することでこの問題を解決し、実行時コストはゼロです。

ブランドパターン

コアとなる技法は、交差型とファントムブランドプロパティを使用します:

type Brand<T, B> = T & { __brand: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;

__brandプロパティは実行時には存在しません。コンパイル時に消去されます。TypeScriptはブランドが異なるため、UserIdOrderIdを別の型として扱います。

function getUser(id: UserId): User { /* ... */ }
function getOrder(id: OrderId): Order { /* ... */ }

const uid = "user_123" as UserId;
const oid = "ord_456" as OrderId;

getUser(uid); // OK
getUser(oid); // 型エラー:'OrderId' を 'UserId' に割り当て不可

より安全な方法として、ユニークシンボルを使用します:

declare const UserIdBrand: unique symbol;
type UserId = string & { [UserIdBrand]: true };

declare const OrderIdBrand: unique symbol;
type OrderId = string & { [OrderIdBrand]: true };

型ガードとバリデーション関数

ブランドは実行時に存在しないため、APIエンドポイントやデータベース結果などのシステム境界で値を検証してブランド化する必要があります。

// バリデーション付きファクトリ関数
function createUserId(value: string): UserId {
  if (!/^user_[a-f0-9]{24}$/.test(value)) {
    throw new Error(`不正なUserId形式: ${value}`);
  }
  return value as UserId;
}

// 型ガード
function isUserId(value: string): value is UserId {
  return /^user_[a-f0-9]{24}$/.test(value);
}

// アサーション関数
function assertUserId(value: string): asserts value is UserId {
  if (!isUserId(value)) {
    throw new Error(`有効なUserIdが必要です: ${value}`);
  }
}

複合型のバリデーターを構成し、逆シリアライズを処理します:

interface User {
  id: UserId;
  email: Email;
  name: string;
}

function parseUser(data: unknown): User {
  const raw = data as Record<string, unknown>;
  return {
    id: createUserId(String(raw.id)),
    email: createEmail(String(raw.email)),
    name: String(raw.name),
  };
}

ドメインプリミティブのユースケース

Branded Typesは、プリミティブの混同が深刻な問題を引き起こすドメインで特に効果を発揮します。

ドメインブランド型防止できるもの
EコマースUserId, OrderId, ProductIdID相互代入エラー
フィンテックUSD, JPY, EUR通貨計算ミス
医療PatientId, ProviderId, ClaimId患者間のデータ漏洩
SaaSApiKey, SessionToken認証情報の誤用

通貨処理は説得力のある例です:

type USD = Brand<number, "USD">;
type JPY = Brand<number, "JPY">;

function addUSD(a: USD, b: USD): USD {
  return (a + b) as USD;
}

function addJPY(a: JPY, b: JPY): JPY {
  return (a + b) as JPY;
}

const price: USD = 29.99 as USD;
const tax: USD = 3.00 as USD;
const yenAmount: JPY = 5000 as JPY;

addUSD(price, tax);       // OK
addUSD(price, yenAmount); // 型エラー:'JPY' を 'USD' に割り当て不可

ジェネリックブランド型とライブラリ

一般的なブランドパターン用のジェネリックユーティリティを作成します:

type Entity<T extends string> = Brand<string, T>;

interface Repository<T, TId extends string> {
  findById(id: Entity<TId>): Promise<T>;
  save(entity: T): Promise<void>;
}

// Zodを使用したランタイム検証+ブランド
import { z } from "zod";

const UserIdSchema = z.string().brand("UserId");
type UserId = z.infer<typeof UserIdSchema>;

const userRepo: Repository<User, "UserId"> = {
  async findById(id) {
    return db.query("SELECT * FROM users WHERE id = $1", [id]);
  },
  async save(user) { /* ... */ },
};
ライブラリブランド手法実行時コスト
type-festOpaque<T, Token>ゼロ
io-tsブランド付きコーデック検証のみ
zod.brand() メソッド検証のみ
手動実装交差型ゼロ

パフォーマンスと移行戦略

Branded Typesの実行時オーバーヘッドはゼロです。ブランドプロパティはコンパイル時に消去されます。バリデーション関数には実行時コストがありますが、システム境界で一度検証し、内部コードベースではブランド型を伝播させることで最小化できます。

// 境界で検証し、内部では伝播させる
async function handleRequest(rawId: string) {
  const userId = createUserId(rawId);
  const user = await userRepo.findById(userId);
  await sendEmail(user.email, welcomeMessage);
}

移行は段階的に行います。最も重要なドメインプリミティブ(ID、通貨額)から始め、モジュール境界にブランド型を追加し、リントルールで使用を強制します。新規コードから始めるか、一度に1モジュールずつリファクタリングすることを推奨します。

Branded Typesはゼロコストのコンパイル時安全性を提供し、型を通じてドメインの意味を伝えることでコードの可読性を向上させ、バグのクラス全体を防止します。学習曲線と若干の冗長性を許容する価値は、安全性の利点がはるかに上回ります。システム境界でブランド型を統合し、型チェッカーにドメインロジックの保護を任せましょう。