TypeScriptブランド型パターン:型安全ドメインモデルを構築する6つのコアパターン
前端工程
型安全性の4つのペインポイント
UserIdとOrderIdをどちらもstringと定義すると、関数の引数を逆に渡してもコンパイラがエラーを出さない。メールアドレスと普通の文字列が相互に代入可能で、実行時にフォーマットエラーが発覚する。APIレスポンスのJSONをas Userでアサートして終わり、型安全性が形骸化する。ドメインモデルでDraftOrderとApprovedOrderが同じインターフェースを共有し、状態遷移は開発者の自覚に依存する。TypeScriptの構造的型システムはすべてのstringを同じに見せる——ドメインの概念が型レベルで完全に消失する。
本記事では核心概念から出発し、基本Branded Type→Zod検証統合→スマートコンストラクタ→ドメインモデル合成→シリアライズ/デシリアライズ→不変ステートマシンの6つのコアパターンを完了し、コンパイル時と実行時の二重安全なドメインモデルを構築する。
核心概念
| 概念 | 説明 |
|---|---|
| Branded Type | 交差型でプリミティブ型にブランドマーカーを付与し、名義型効果を実現。例:string & { __brand: 'UserId' } |
| Nominal Type | 名義型システム。型の互換性が宣言名に基づく。TypeScriptはネイティブでサポートせず、Branded Typeでシミュレートする必要がある |
| Structural Type | 構造的型システム。TypeScriptのデフォルト。構造の互換性があれば代入可能。UserIdとOrderIdを区別できない |
| ブランドシンボル | ブランド型の一意識別子。通常__brandや_brandなどの到達不可能なプロパティ名を使用。実行時には存在しない |
| 型ナローイング | 型ガードを通じて広い型をブランド型に絞り込み、制御フローで異なるブランドを正確に区別する |
| Zod検証 | 実行時型検証ライブラリ。ブランド型と組み合わせてコンパイル時+実行時の二重安全性を実現 |
| 型ガード | 型述語val is BrandTypeを返す関数。実行時チェックを行いながらコンパイル時の型をナローイングする |
| 不変型 | readonlyとReadonlyでブランド型インスタンスの不変性を確保し、ドメインオブジェクトの意図しない変更を防止する |
ブランド型ワークフロー
コンパイル時安全性:
Branded Type定義 → スマートコンストラクタ(検証) → 型ガード(ナローイング) → ドメインモデル合成
実行時安全性:
Zod Schema検証 → コンストラクタチェック → シリアライズ/デシリアライズ → ステートマシン制約
エンドツーエンド安全性:
API境界Zod検証 → ブランド型構築 → ビジネスロジック型制約 → シリアライズ出力検証
問題分析:ブランド型の5つの課題
- 構造的型システムの制限:TypeScriptは構造的型を採用しており、
type UserId = stringとtype OrderId = stringは完全に等価。コンパイル時に異なるドメインの同型型を区別できず、Branded Typeで名義型をシミュレートする必要がある - 実行時とコンパイル時の型の不一致:ブランドマーカー
__brandは実行時に消去される。コンパイル時に精密なブランド型でも実行時のデータの正当性を保証できず、API境界では実行時検証の併用が必須 - ブランド型のシリアライズ困難:JSONシリアライズ時にブランドマーカーが消失。デシリアライズ後にブランド型を再構築する必要があり、ネットワーク伝送や永続化のシナリオで特別な処理が必要
- ジェネリクス制約の複雑さ:ブランド型とジェネリクスの組み合わせで制約の表現が困難。
<T extends Brand<string>>の推論とナローイングには型関係の慎重な設計が必要 - ライブラリ型拡張の制限:サードパーティライブラリの型定義にブランドマーカーを直接追加できず、モジュール拡張や型マッピングによる間接的な拡張が必要
ステップバイステップ:6つのコアパターン
パターン1:基本Branded Type定義と使用
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type Email = Brand<string, 'Email'>;
type PositiveInt = Brand<number, 'PositiveInt'>;
function createUserId(value: string): UserId {
return value as UserId;
}
function createOrderId(value: string): OrderId {
return value as OrderId;
}
const userId: UserId = createUserId('user-001');
const orderId: OrderId = createOrderId('order-001');
// ❌ コンパイルエラー:OrderIdをUserIdに代入できない
// const wrong: UserId = orderId;
function getUser(id: UserId): string {
return `User: ${id}`;
}
getUser(userId);
// ❌ getUser(orderId); // 型エラー:OrderIdはUserIdに代入不可
パターン2:ブランド型とZod検証の統合
import { z } from 'zod';
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;
type Age = Brand<number, 'Age'>;
const UserIdSchema = z.string().uuid().transform((v) => v as UserId);
const EmailSchema = z.string().email().transform((v) => v as Email);
const AgeSchema = z.number().int().min(0).max(150).transform((v) => v as Age);
function parseUserId(value: unknown): UserId {
return UserIdSchema.parse(value);
}
function parseEmail(value: unknown): Email {
return EmailSchema.parse(value);
}
function parseAge(value: unknown): Age {
return AgeSchema.parse(value);
}
const userId = parseUserId('550e8400-e29b-41d4-a716-446655440000');
const email = parseEmail('user@example.com');
const age = parseAge(25);
// ❌ parseUserId('not-a-uuid'); // ZodError
// ❌ parseEmail('invalid-email'); // ZodError
// ❌ parseAge(-1); // ZodError
const UserSchema = z.object({
id: UserIdSchema,
email: EmailSchema,
age: AgeSchema,
name: z.string().min(1),
});
type User = z.infer<typeof UserSchema>;
function safeParseUser(data: unknown) {
return UserSchema.safeParse(data);
}
パターン3:ブランド型スマートコンストラクタ
type Brand<T, B extends string> = T & { readonly __brand: B };
type Result<T, E = string> =
| { success: true; value: T }
| { success: false; error: E };
class BrandConstructor<T, B extends string> {
constructor(
private readonly brand: B,
private readonly validate: (value: T) => boolean,
private readonly errorMessage: string,
) {}
create(value: T): Brand<T, B> {
if (!this.validate(value)) {
throw new Error(`${this.brand}: ${this.errorMessage}`);
}
return value as Brand<T, B>;
}
safeCreate(value: T): Result<Brand<T, B>> {
if (!this.validate(value)) {
return { success: false, error: `${this.brand}: ${this.errorMessage}` };
}
return { success: true, value: value as Brand<T, B> };
}
isBrand(value: T): value is Brand<T, B> {
return this.validate(value);
}
unwrap(value: Brand<T, B>): T {
return value as T;
}
}
type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;
type PositiveInt = Brand<number, 'PositiveInt'>;
const UserIdBrand = new BrandConstructor<string, 'UserId'>(
'UserId',
(v) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v),
'Invalid UUID format',
);
const EmailBrand = new BrandConstructor<string, 'Email'>(
'Email',
(v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
'Invalid email format',
);
const PositiveIntBrand = new BrandConstructor<number, 'PositiveInt'>(
'PositiveInt',
(v) => Number.isInteger(v) && v > 0,
'Must be a positive integer',
);
const userId = UserIdBrand.create('550e8400-e29b-41d4-a716-446655440000');
const emailResult = EmailBrand.safeCreate('user@example.com');
const ageResult = PositiveIntBrand.safeCreate(-5);
if (emailResult.success) {
console.log(emailResult.value);
}
パターン4:ドメインモデル型安全合成
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type Money = Brand<number, 'Money'>;
type SKU = Brand<string, 'SKU'>;
interface OrderLine {
sku: SKU;
quantity: PositiveInt;
unitPrice: Money;
}
interface DraftOrder {
readonly status: 'draft';
readonly id: OrderId;
readonly customerId: UserId;
readonly lines: readonly OrderLine[];
readonly createdAt: Date;
}
interface ApprovedOrder {
readonly status: 'approved';
readonly id: OrderId;
readonly customerId: UserId;
readonly lines: readonly OrderLine[];
readonly approvedBy: UserId;
readonly approvedAt: Date;
}
interface ShippedOrder {
readonly status: 'shipped';
readonly id: OrderId;
readonly customerId: UserId;
readonly lines: readonly OrderLine[];
readonly approvedBy: UserId;
readonly trackingNumber: string;
readonly shippedAt: Date;
}
type Order = DraftOrder | ApprovedOrder | ShippedOrder;
function approveOrder(order: DraftOrder, approvedBy: UserId): ApprovedOrder {
return {
...order,
status: 'approved',
approvedBy,
approvedAt: new Date(),
};
}
function shipOrder(order: ApprovedOrder, trackingNumber: string): ShippedOrder {
return {
...order,
status: 'shipped',
trackingNumber,
shippedAt: new Date(),
};
}
// ❌ approveOrder(approvedOrder, userId); // 型エラー:ApprovedOrderはDraftOrderに代入不可
// ❌ shipOrder(draftOrder, 'tracking'); // 型エラー:DraftOrderはApprovedOrderに代入不可
function getOrderTotal(order: Order): Money {
const total = order.lines.reduce(
(sum, line) => sum + (line.unitPrice as number) * (line.quantity as number),
0,
);
return total as Money;
}
パターン5:ブランド型シリアライズとデシリアライズ
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;
interface SerializableBrand<T, B extends string> {
serialize(value: Brand<T, B>): T;
deserialize(raw: unknown): Brand<T, B>;
}
function createSerializableBrand<T, B extends string>(
brand: B,
validate: (value: T) => boolean,
): SerializableBrand<T, B> {
return {
serialize(value: Brand<T, B>): T {
return value as T;
},
deserialize(raw: unknown): Brand<T, B> {
if (typeof raw !== 'string' || !validate(raw as T)) {
throw new Error(`Invalid ${brand} value: ${String(raw)}`);
}
return raw as Brand<T, B>;
},
};
}
const UserIdSerDe = createSerializableBrand<string, 'UserId'>(
'UserId',
(v) => /^[0-9a-f]{8}-[0-9a-f]{4}/i.test(v),
);
const EmailSerDe = createSerializableBrand<string, 'Email'>(
'Email',
(v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
);
interface UserDTO {
id: string;
email: string;
name: string;
}
interface User {
readonly id: UserId;
readonly email: Email;
readonly name: string;
}
function toDTO(user: User): UserDTO {
return {
id: UserIdSerDe.serialize(user.id),
email: EmailSerDe.serialize(user.email),
name: user.name,
};
}
function fromDTO(dto: unknown): User {
if (typeof dto !== 'object' || dto === null) {
throw new Error('Invalid user DTO');
}
const raw = dto as Record<string, unknown>;
return {
id: UserIdSerDe.deserialize(raw.id),
email: EmailSerDe.deserialize(raw.email),
name: String(raw.name),
};
}
const user: User = {
id: '550e8400-e29b-41d4-a716-446655440000' as UserId,
email: 'user@example.com' as Email,
name: 'Alice',
};
const dto = toDTO(user);
const restored = fromDTO(dto);
パターン6:不変ブランド型とステートマシン
type Brand<T, B extends string> = T & { readonly __brand: B };
type OrderId = Brand<string, 'OrderId'>;
type UserId = Brand<string, 'UserId'>;
type Draft = 'draft';
type Approved = 'approved';
type Shipped = 'shipped';
type Cancelled = 'cancelled';
interface OrderState<S extends string> {
readonly _tag: S;
}
interface DraftOrder extends OrderState<Draft> {
readonly id: OrderId;
readonly customerId: UserId;
readonly items: readonly string[];
}
interface ApprovedOrder extends OrderState<Approved> {
readonly id: OrderId;
readonly customerId: UserId;
readonly items: readonly string[];
readonly approvedBy: UserId;
readonly approvedAt: Date;
}
interface ShippedOrder extends OrderState<Shipped> {
readonly id: OrderId;
readonly customerId: UserId;
readonly items: readonly string[];
readonly approvedBy: UserId;
readonly trackingNumber: string;
readonly shippedAt: Date;
}
interface CancelledOrder extends OrderState<Cancelled> {
readonly id: OrderId;
readonly customerId: UserId;
readonly reason: string;
readonly cancelledAt: Date;
}
type AnyOrder = DraftOrder | ApprovedOrder | ShippedOrder | CancelledOrder;
type TransitionMap = {
draft: { approve: Approved; cancel: Cancelled };
approved: { ship: Shipped; cancel: Cancelled };
shipped: {};
cancelled: {};
};
type NextState<S extends string, E extends string> =
S extends keyof TransitionMap
? E extends keyof TransitionMap[S]
? TransitionMap[S][E]
: never
: never;
function transition<S extends keyof TransitionMap, E extends keyof TransitionMap[S]>(
order: AnyOrder & OrderState<S>,
event: E,
data: TransitionPayload<S, E>,
): AnyOrder & OrderState<NextState<S, E>> {
switch (event) {
case 'approve':
return {
...order,
_tag: 'approved',
approvedBy: (data as { approvedBy: UserId }).approvedBy,
approvedAt: new Date(),
} as AnyOrder & OrderState<NextState<S, E>>;
case 'ship':
return {
...order,
_tag: 'shipped',
trackingNumber: (data as { trackingNumber: string }).trackingNumber,
shippedAt: new Date(),
} as AnyOrder & OrderState<NextState<S, E>>;
case 'cancel':
return {
id: order.id,
customerId: order.customerId,
_tag: 'cancelled',
reason: (data as { reason: string }).reason,
cancelledAt: new Date(),
} as AnyOrder & OrderState<NextState<S, E>>;
default:
throw new Error(`Invalid event: ${String(event)}`);
}
}
type TransitionPayload<S extends string, E extends string> =
E extends 'approve' ? { approvedBy: UserId } :
E extends 'ship' ? { trackingNumber: string } :
E extends 'cancel' ? { reason: string } :
never;
よくある落とし穴
落とし穴1:typeエイリアスでブランド型を代用
// ❌ typeエイリアスは新しい型を作成しない。UserIdとstringは完全に等価
type UserId = string;
type OrderId = string;
const userId: UserId = '123';
const orderId: OrderId = userId; // コンパイル成功!型安全性が失われる
// ✅ Brand交差型で名義型を作成
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
const orderId: OrderId = userId as UserId; // ❌ コンパイルエラー
落とし穴2:到達可能なブランドプロパティの使用
// ❌ 到達可能なプロパティは実行時に副作用を生み、シリアライズと比較に影響する
type BadBrand<T, B extends string> = T & { brand: B };
const id: BadBrand<string, 'UserId'> = Object.assign('123', { brand: 'UserId' });
JSON.stringify(id); // "123" — ブランドマーカーが消失
// ✅ 到達不可能なブランドシンボルプロパティを使用
type Brand<T, B extends string> = T & { readonly __brand: B };
落とし穴3:実行時検証をスキップしてアサーション
// ❌ 直接asアサーションで検証をバイパス。実行時データが不正な可能性
const email = 'not-an-email' as Email;
// ✅ コンストラクタやZod検証で実行時の正当性を確保
const email = EmailBrand.create('user@example.com');
落とし穴4:ジェネリクスでブランド情報が消失
// ❌ ジェネリクス推論時にブランド情報が消去される可能性
function first<T>(arr: T[]): T { return arr[0]; }
const ids: UserId[] = [createUserId('1')];
const id = first(ids); // UserId ✓ ただし不適切な制約ではstringに退化する可能性
// ✅ ブランド型をジェネリクス制約として使用
function firstBrand<T extends Brand<string, string>>(arr: T[]): T { return arr[0]; }
落とし穴5:デシリアライズ検証の忘れ
// ❌ JSON.parseから直接ブランド型をアサートし、検証をスキップ
const user = JSON.parse(json) as User;
// ✅ SerDeを通じてデシリアライズし、データの正当性を検証
const user = fromDTO(JSON.parse(json));
エラートラブルシューティング
| エラー現象 | 可能な原因 | 解決策 |
|---|---|---|
Type 'string' is not assignable to type 'Brand<string, "X">' |
プリミティブ値をブランド型に直接代入 | コンストラクタまたは型アサーションでブランド型を作成 |
Type 'Brand<string, "A">' is not assignable to type 'Brand<string, "B">' |
異なるブランド型間の代入 | ブランドマーカーが一致するか確認、型の対応関係を確認 |
Property '__brand' does not exist on type 'string' |
プリミティブ型でブランドプロパティにアクセス | ブランドプロパティはコンパイル時のみ。実行時には存在しない |
| Zod検証は通過するが型が一致しない | z.transformの戻り値型がブランド型と不一致 |
transformコールバックがvalue as BrandTypeを返すことを確認 |
| シリアライズ後にブランド情報が消失 | JSON.stringifyが非列挙可能プロパティを無視 | ブランドマーカーは到達不可能な設計。プリミティブ値のみシリアライズ |
ジェネリクスでブランド型がunknownと推論 |
ジェネリクス制約にブランド型の境界がない | T extends Brand<string, string>制約を使用 |
| 型ガードがナローイングしない | is述語型が実際のブランド型と一致しない |
型ガードがvalue is BrandTypeで正確に一致することを確認 |
モジュール拡張で__brandが存在しないとエラー |
サードパーティの型定義がブランドプロパティを宣言していない | declare moduleで型定義を拡張 |
交差型がneverを生成 |
ブランドマーカーが既存プロパティと競合 | __brand_Xのような固有のブランドプロパティ名で競合を回避 |
| 実行時型チェックが欠落 | コンパイル時ブランド型のみに依存 | API境界でZodまたはコンストラクタによる実行時検証を使用 |
高度な最適化
ブランド型ユーティリティライブラリ
type Brand<T, B extends string> = T & { readonly __brand: B };
type Unbrand<T> = T extends Brand<infer U, string> ? U : T;
type BrandOf<T> = T extends Brand<any, infer B> ? B : never;
type IsBranded<T> = T extends Brand<any, string> ? true : false;
type ReplaceBrand<T, B extends string> = T extends Brand<infer U, string> ? Brand<U, B> : never;
type BrandedKeys<T> = { [K in keyof T]: IsBranded<T[K]> extends true ? K : never }[keyof T];
条件付きブランド型
type MaybeBrand<T, B extends string, ShouldBrand extends boolean> =
ShouldBrand extends true ? Brand<T, B> : T;
type ConfigValue<T, Branded extends boolean = true> = {
id: MaybeBrand<string, 'ConfigId', Branded>;
value: T;
updatedAt: Date;
};
ブランド型とEffectシステムの統合
type Brand<T, B extends string> = T & { readonly __brand: B };
type Validated<T> = Brand<T, 'Validated'>;
type Sanitized<T> = Brand<T, 'Sanitized'>;
function validate<T>(value: T, predicate: (v: T) => boolean): Validated<T> | Error {
return predicate(value) ? value as Validated<T> : new Error('Validation failed');
}
function sanitize(input: string): Sanitized<string> {
return input.replace(/[<>"'&]/g, '') as Sanitized<string>;
}
比較分析
| 特徴 | Branded Type | Opaque Type | io-ts | runtypes |
|---|---|---|---|---|
| 実装方式 | 交差型+ブランドプロパティ | 条件型+ユニークシンボル | 実行時コーデック | 実行時型チェック |
| コンパイル時安全性 | ★★★★★ | ★★★★★ | ★★★★ | ★★★★ |
| 実行時安全性 | Zodとの併用が必要 | Zodとの併用が必要 | ★★★★★ | ★★★★★ |
| 学習コスト | 低 | 中 | 高 | 中 |
| バンドルサイズ | 0 | 0 | ~15KB | ~8KB |
| シリアライズ対応 | 手動実装が必要 | 手動実装が必要 | ★★★★★ | ★★★★ |
| ジェネリクス対応 | ★★★★ | ★★★ | ★★★★ | ★★★ |
| エコシステム統合 | Zod/Effect | Zod | fp-ts | — |
| TypeScriptネイティブ | はい | はい | いいえ | いいえ |
| 本番推奨度 | ★★★★★ | ★★★★ | ★★★ | ★★★ |
ブランド型は「型体操」の飾りではない——ドメイン駆動設計の型安全な基盤である。
UserIdとOrderIdをコンパイル時に明確に区別し、Zod検証で実行時のデータ境界を守り、ドメインモデルの状態遷移を型レベルで検証可能にする。型安全性の最高到達点は最も複雑な型を書くことではなく、不正な状態を表現不可能にすることである。
おすすめツール
- JSONフォーマッター — APIレスポンスJSONをフォーマットし、ブランド型シリアライズ問題を迅速にトラブルシュート
- コードフォーマッター — TypeScriptコードをフォーマットし、ブランド型定義スタイルを統一
- cURL to Code — APIリクエストを型安全なTypeScriptブランド型fetchコードに変換
ブラウザローカルツールを無料で試す →
#TypeScript品牌类型#Branded Type#类型安全#Nominal Type#类型编程#2026#前端工程