【実務・中級編】Type Aliasにおける「ブランド型(Branded Types)」によるドメイン駆動設計 – TypeScript コア・型システムの基礎解析バイブル

TypeScriptの型システムは、その柔軟性ゆえに「何でも書けてしまう」という罠を孕んでいます。

コードレビューをしていて、最もゾッとする瞬間のひとつがこれです。

type UserId = string;
type OrderId = string;

function cancelOrder(userId: UserId, orderId: OrderId) {
// …
}

const userId: UserId = “order_998877”; // ああっ!逆に入れている!
const orderId: OrderId = “user_123456”;

cancelOrder(userId, orderId); // TypeScriptコンパイラは何も文句を言わない

`string` というプリミティブのベールに包まれた瞬間、TypeScriptの強力な型チェックは機能を停止し、単なる「文字列の詰め合わせ」と化します。ドメイン駆動設計(DDD)において、`UserId` と `OrderId` は概念として完全に別物であり、混同することは致命的なバグです。

今回は、ランタイムのオーバーヘッドを一切発生させずに、コンパイル時のみこの「ドメインの境界」を厳格に強制するブランド型(Branded Types / Nominal Typingのシミュレーション)の極限の深淵へあなたを案内します。

—

1. ブランド型とは何か?なぜ構造的型付けの壁を突破できるのか

TypeScriptは構造的型付け(Structural Subtyping)を採用しています。つまり、プロパティの構造が同じであれば、たとえ名前が違っていても互換性があるとみなされます。

type USD = number;
type JPY = number;

const priceUSD: USD = 100;
const priceJPY: JPY = priceUSD; // コンパイルエラーにならない!大変だ!

これでは実務のドメインモデル設計において使い物になりません。そこで、「型システム上だけに存在する、絶対に衝突しないタグ(ブランド)」を交差型(Intersection Types)で付与します。

これがブランド型です。

// ブランド型を生成するためのユーティリティ型
type Brand = T & { readonly __brand: U };

ここで `readonly __brand: U` というプロパティを噛み合わせます。ランタイムにおいて、このプロパティが実際にオブジェクトに存在する必要はありません(存在させないのがポイントです)。型チェッカーの目玉を欺くための「幻の目印」として機能させます。

—

2. 実践:プロダクションレベルのドメインモデル設計

フロントエンドのフォーム入力からAPI境界、そしてドメイン層に至るまで、データの信頼性を担保する実用的なコードを見ていきましょう。

ここでは、ECサイトにおける「ユーザーID」「商品ID」「価格(日本円)」をブランド型で厳格に分離・管理する例を実装します。

// ==========================================
// 1. ブランド型のプリミティブ定義
// ==========================================
export type UserId = string & { readonly __brand: unique symbol };
export type ProductId = string & { readonly __brand: unique symbol };
export type JPY = number & { readonly __brand: unique symbol };

// ==========================================
// 2. 智能コンストラクタ(Smart Constructors)
// 値を生成する唯一の入口。ここでバリデーションとブランディングを行う。
// ==========================================
export class DomainValidator {
static createUserId(raw: string): UserId {
if (!raw.startsWith(‘usr_’)) {
throw new Error(`Invalid UserId format: ${raw}`);
}
// 型アサーションはここで一箇所にカプセル化する
return raw as unknown as UserId;
}

static createProductId(raw: string): ProductId {
if (!raw.startsWith(‘prd_’)) {
throw new Error(`Invalid ProductId format: ${raw}`);
}
return raw as unknown as ProductId;
}

static createJPY(raw: number): JPY {
if (raw < 0 || !Number.isInteger(raw)) { throw new Error(`Invalid JPY amount: ${raw}`); } return raw as unknown as JPY; } } // ========================================== // 3. ドメインモデル / ユースケース層 // ========================================== interface PurchaseOrder { id: string; userId: UserId; productId: ProductId; amount: JPY; } function processCheckout(order: PurchaseOrder): void { console.log(`Processing order for user ${order.userId} with product ${order.productId}, total: ¥${order.amount}`); } // --- 使用例 --- // APIレスポンスやユーザー入力を想定 const rawInputUserId = "usr_98765"; const rawInputProductId = "prd_11111"; const rawInputAmount = 5000; // 正しいコンストラクタを通すことで、安全にブランド型に昇格する const validUserId = DomainValidator.createUserId(rawInputUserId); const validProductId = DomainValidator.createProductId(rawInputProductId); const validAmount = DomainValidator.createJPY(rawInputAmount); // 正常系 processCheckout({ id: "ord_001", userId: validUserId, productId: validProductId, amount: validAmount, }); / -------------------------------------------------- コンパイルエラーの検証(ここで型安全性が爆発的に向上する) -------------------------------------------------- / // ❌ エラー: ユーザーIDに商品IDを渡そうとした場合 // processCheckout({ // id: "ord_002", // userId: validProductId, // Type 'ProductId' is not assignable to type 'UserId'. // productId: validUserId, // Type 'UserId' is not assignable to type 'ProductId'. // amount: validAmount, // }); // ❌ エラー: 生のプリミティブ値を直接ドメイン層に突っ込もうとした場合 // processCheckout({ // id: "ord_003", // userId: "usr_98765", // Type 'string' is not assignable to type 'UserId'. // productId: validProductId, // amount: validAmount, // }); ---

3. なぜ `unique symbol` なのか?(アーキテクトの深掘り知見)

初学者はよく、単なる文字列リテラルでブランドを定義しがちです。

// 避けるべきアンチパターン
type BadUserId = string & { __brand: “UserId” };

なぜこれが「甘い」のか?

TypeScriptの構造的型付けにおいて、同じ文字列リテラル `”UserId”` を持つ別の型が万が一現れた場合、意図しない型互換性が生まれるリスクがあります。また、拡張性や衝突回避の観点から、ES2015の `symbol`、さらにユニーク性を保証する `unique symbol` を使うのがモダンTypeScriptのベストプラクティスです。

// 完璧な一意性
declare const __brand: unique symbol;
type Brand = T & { readonly [__brand]: TBrand };

このアプローチにより、モジュールをまたいでもブランドが衝突することは絶対にありません。

—

4. パフォーマンスとコンパイル時のコストについて

「こんな複雑な型を導入したら、tscの型チェックが重くなるのでは?」という懸念を持つエンジニアは優秀です。

結論から言うと、ブランド型の実行時コストはゼロです。なぜなら、コンパイル後のJavaScriptコードには `__brand` プロパティも、スマートコンストラクタの型アサーションも一切残らず、単なる `string` や `number` として出力されるからです。

TypeScript (TypeScriptコード):

const userId = DomainValidator.createUserId(“usr_123”);

JavaScript (出力されるコード):

const userId = “usr_123”;

ランタイムのメモリフットプリントを一切増やすことなく、開発者の認知負荷とバグの温床をコンパイラに肩代わりさせる――これこそが、TypeScriptの型システムを限界まで搾り取るアーキテクチャ設計です。

—

5. 現場で直面する課題:外部APIやDBとの境界(Boundary)

フロントエンド開発において最大の壁となるのが、JSON.parseやAxios経由で取得する「型のない外部データ(Unknown Data)」です。ここで `as UserId` と安易に型キャストしては、ブランド型の意味が完全に失われます。

外部境界では必ず Zod などのランタイムバリデーションライブラリとブランド型を統合させましょう。

import { z } from ‘zod’;

// Zodスキーマの定義
const UserIdSchema = z.string().startsWith(‘usr_’).brand<'UserId'>();
type ZodUserId = z.infer;

// APIレスポンスの安全なパース
const responseJson = { userId: “usr_abc123” };
const result = UserIdSchema.safeParse(responseJson.userId);

if (result.success) {
// result.data は安全に ZodUserId 型として扱える
const safeUserId: UserId = result.data as unknown as UserId;
}

—

まとめ:型は「ドキュメント」であり「防壁」である

型エイリアスにブランドを付与するテクニックは、コードの意図を明確にする最強のドキュメントであり、開発者のうっかりミスをビルド段階で確実にハントする防壁です。

「文字列だから」「数値だから」という理由で、全てのプリミティブをそのまま関数の引数に垂れ流す設計は、今日で終わり目にしましょう。ドメインの境界線を型で引き裂き、変更に強く、絶対にバグらないコードベースをあなたのプロダクトに実装してください。

タイトルとURLをコピーしました