最終更新:
TypeScriptブランド型実践: 型安全なドメインモデリング
ブランド型(Branded Types)とは
ブランド型は、プリミティブ型に「ブランド」という見えないマーカーを付けることで、同じ基底型でも異なる型として扱えるようにするTypeScriptのテクニックです。
なぜブランド型が必要か
// ❌ 問題: すべてstringで型安全性がない
type UserId = string;
type Email = string;
type OrderId = string;
function sendEmail(email: Email, userId: UserId) {
console.log(`Sending to ${email} for user ${userId}`);
}
const userId: UserId = "user-123";
const email: Email = "user@example.com";
// バグ: 引数の順序を間違えてもコンパイルエラーにならない
sendEmail(userId, email); // 実行時エラーの原因
// ✅ 解決: ブランド型で型レベルで区別
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;
type OrderId = Brand<string, 'OrderId'>;
function sendEmail(email: Email, userId: UserId) {
console.log(`Sending to ${email} for user ${userId}`);
}
const userId = "user-123" as UserId;
const email = "user@example.com" as Email;
sendEmail(userId, email); // ❌ コンパイルエラー!
sendEmail(email, userId); // ✅ OK
基本的な実装パターン
1. シンプルなブランド型
// ブランド型の基本定義
type Brand<K, T> = K & { readonly __brand: T };
// 使用例
type UserId = Brand<string, 'UserId'>;
type ProductId = Brand<number, 'ProductId'>;
type Timestamp = Brand<number, 'Timestamp'>;
// コンストラクタ関数
function UserId(value: string): UserId {
return value as UserId;
}
function ProductId(value: number): ProductId {
if (value <= 0) {
throw new Error('ProductId must be positive');
}
return value as ProductId;
}
function Timestamp(value: number = Date.now()): Timestamp {
return value as Timestamp;
}
// 使用
const userId = UserId("user-123");
const productId = ProductId(42);
const timestamp = Timestamp();
2. バリデーション付きブランド型
type Email = Brand<string, 'Email'>;
function Email(value: string): Email {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
throw new Error(`Invalid email: ${value}`);
}
return value as Email;
}
// より安全なResult型を返すパターン
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseEmail(value: string): Result<Email> {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
return { ok: false, error: new Error(`Invalid email: ${value}`) };
}
return { ok: true, value: value as Email };
}
// 使用
const result = parseEmail("user@example.com");
if (result.ok) {
const email: Email = result.value;
console.log(email);
}
3. 複数のブランドを持つ型
type ValidatedEmail = Brand<string, 'Email'> & Brand<string, 'Validated'>;
type UnvalidatedEmail = Brand<string, 'Email'>;
function validateEmail(email: UnvalidatedEmail): ValidatedEmail {
// 実際の検証ロジック(DNS確認など)
return email as ValidatedEmail;
}
function sendEmail(email: ValidatedEmail) {
// 検証済みメールのみ受け付ける
console.log(`Sending to ${email}`);
}
const unvalidated = "user@example.com" as UnvalidatedEmail;
// sendEmail(unvalidated); // ❌ コンパイルエラー
const validated = validateEmail(unvalidated);
sendEmail(validated); // ✅ OK
実践的なドメインモデリング
金額の型安全性
// 通貨と金額のブランド型
type Currency = 'USD' | 'EUR' | 'JPY';
type Money<C extends Currency> = Brand<number, `Money<${C}>`>;
type USD = Money<'USD'>;
type EUR = Money<'EUR'>;
type JPY = Money<'JPY'>;
// コンストラクタ
function USD(cents: number): USD {
return cents as USD;
}
function EUR(cents: number): EUR {
return cents as EUR;
}
function JPY(yen: number): JPY {
return yen as JPY;
}
// 同一通貨のみ演算可能
function addMoney<C extends Currency>(
a: Money<C>,
b: Money<C>
): Money<C> {
return (a + b) as Money<C>;
}
// 使用例
const price1 = USD(1000); // $10.00
const price2 = USD(500); // $5.00
const total = addMoney(price1, price2); // ✅ OK
const euroPrice = EUR(1000);
// const invalid = addMoney(price1, euroPrice); // ❌ コンパイルエラー
URL型の階層構造
type Url = Brand<string, 'Url'>;
type HttpUrl = Brand<Url, 'Http'>;
type HttpsUrl = Brand<HttpUrl, 'Https'>;
function Url(value: string): Url {
try {
new URL(value);
return value as Url;
} catch {
throw new Error(`Invalid URL: ${value}`);
}
}
function HttpUrl(value: string): HttpUrl {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('URL must use HTTP or HTTPS protocol');
}
return value as HttpUrl;
}
function HttpsUrl(value: string): HttpsUrl {
const url = new URL(value);
if (url.protocol !== 'https:') {
throw new Error('URL must use HTTPS protocol');
}
return value as HttpsUrl;
}
// 関数はより厳密な型を要求できる
function fetchSecure(url: HttpsUrl) {
return fetch(url);
}
function fetchAny(url: HttpUrl) {
return fetch(url);
}
const secureUrl = HttpsUrl("https://api.example.com");
fetchSecure(secureUrl); // ✅ OK
fetchAny(secureUrl); // ✅ OK (HttpsUrlはHttpUrlのサブタイプ)
const insecureUrl = HttpUrl("http://api.example.com");
// fetchSecure(insecureUrl); // ❌ コンパイルエラー
fetchAny(insecureUrl); // ✅ OK
非空文字列型
type NonEmptyString = Brand<string, 'NonEmptyString'>;
function NonEmptyString(value: string): NonEmptyString {
if (value.trim().length === 0) {
throw new Error('String cannot be empty');
}
return value as NonEmptyString;
}
// 実用例: ユーザー名
type Username = Brand<NonEmptyString, 'Username'>;
function Username(value: string): Username {
const trimmed = value.trim();
if (trimmed.length < 3) {
throw new Error('Username must be at least 3 characters');
}
if (trimmed.length > 20) {
throw new Error('Username must be at most 20 characters');
}
if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) {
throw new Error('Username can only contain alphanumeric characters and underscores');
}
return trimmed as Username;
}
// 使用
function createUser(username: Username) {
// usernameは必ず有効なフォーマット
console.log(`Creating user: ${username}`);
}
const validUsername = Username("john_doe");
createUser(validUsername); // ✅ OK
// createUser("a"); // 実行時エラー: Username must be at least 3 characters
型ガードとの組み合わせ
type PositiveNumber = Brand<number, 'PositiveNumber'>;
type NegativeNumber = Brand<number, 'NegativeNumber'>;
function isPositive(n: number): n is PositiveNumber {
return n > 0;
}
function isNegative(n: number): n is NegativeNumber {
return n < 0;
}
// 使用例
function processNumber(n: number) {
if (isPositive(n)) {
// この中ではnはPositiveNumber型
const positive: PositiveNumber = n;
console.log(`Positive: ${positive}`);
} else if (isNegative(n)) {
// この中ではnはNegativeNumber型
const negative: NegativeNumber = n;
console.log(`Negative: ${negative}`);
} else {
console.log('Zero');
}
}
Zodとの統合
import { z } from 'zod';
type Email = Brand<string, 'Email'>;
const EmailSchema = z.string().email().transform((val) => val as Email);
type ParsedEmail = z.infer<typeof EmailSchema>; // Email型
// 使用例
const result = EmailSchema.safeParse("user@example.com");
if (result.success) {
const email: Email = result.data;
sendEmail(email);
}
// より複雑な例: ユーザー作成
type UserId = Brand<string, 'UserId'>;
type Username = Brand<string, 'Username'>;
const CreateUserSchema = z.object({
email: z.string().email().transform((val) => val as Email),
username: z
.string()
.min(3)
.max(20)
.regex(/^[a-zA-Z0-9_]+$/)
.transform((val) => val as Username),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
async function createUser(input: CreateUserInput) {
// input.emailとinput.usernameは既にブランド型
const userId = generateId() as UserId;
await db.user.create({
data: {
id: userId,
email: input.email,
username: input.username,
},
});
return userId;
}
パフォーマンスとランタイム
// ブランド型はランタイムコスト0
const before = "user@example.com";
const after = before as Email;
console.log(before === after); // true (同じオブジェクト)
// バリデーション関数のみランタイムコスト
function Email(value: string): Email {
// このバリデーションのみランタイムで実行される
if (!value.includes('@')) {
throw new Error('Invalid email');
}
return value as Email;
}
実践例: Eコマースドメイン
// ドメイン型定義
type ProductId = Brand<string, 'ProductId'>;
type CustomerId = Brand<string, 'CustomerId'>;
type OrderId = Brand<string, 'OrderId'>;
type Quantity = Brand<number, 'Quantity'> & { readonly __positive: true };
type Price = Brand<number, 'Price'> & { readonly __positive: true };
// コンストラクタ
function ProductId(value: string): ProductId {
if (!value.startsWith('prod_')) {
throw new Error('ProductId must start with "prod_"');
}
return value as ProductId;
}
function Quantity(value: number): Quantity {
if (value <= 0 || !Number.isInteger(value)) {
throw new Error('Quantity must be a positive integer');
}
return value as Quantity;
}
function Price(cents: number): Price {
if (cents < 0) {
throw new Error('Price cannot be negative');
}
return cents as Price;
}
// ドメインロジック
interface OrderItem {
productId: ProductId;
quantity: Quantity;
unitPrice: Price;
}
function calculateItemTotal(item: OrderItem): Price {
return Price(item.unitPrice * item.quantity);
}
interface Order {
orderId: OrderId;
customerId: CustomerId;
items: OrderItem[];
}
function calculateOrderTotal(order: Order): Price {
const total = order.items.reduce(
(sum, item) => sum + calculateItemTotal(item),
0
);
return Price(total);
}
// 使用例
const order: Order = {
orderId: "order-123" as OrderId,
customerId: "cust-456" as CustomerId,
items: [
{
productId: ProductId("prod_001"),
quantity: Quantity(2),
unitPrice: Price(1000), // $10.00
},
{
productId: ProductId("prod_002"),
quantity: Quantity(1),
unitPrice: Price(2500), // $25.00
},
],
};
const total = calculateOrderTotal(order);
console.log(`Order total: $${total / 100}`); // $45.00
ユーティリティ型
// ブランドを剥がす
type Unbrand<T> = T extends Brand<infer K, any> ? K : T;
type UserId = Brand<string, 'UserId'>;
type PlainString = Unbrand<UserId>; // string
// ブランドを取得
type GetBrand<T> = T extends Brand<any, infer B> ? B : never;
type UserIdBrand = GetBrand<UserId>; // 'UserId'
// 複数のブランドを結合
type MultiBrand<K, Brands extends readonly string[]> = Brands extends readonly [
infer First extends string,
...infer Rest extends readonly string[]
]
? Brand<MultiBrand<K, Rest>, First>
: K;
type ValidatedVerifiedEmail = MultiBrand<string, ['Email', 'Validated', 'Verified']>;
まとめ
TypeScriptのブランド型は、プリミティブ型に意味を持たせることで、型レベルでドメインロジックを表現できる強力なテクニックです。コンパイル時の型チェックを活用し、実行時エラーを未然に防ぐことができます。
ブランド型を使うべき場面
- 同じ基底型でも意味が異なる値(UserId、Email、OrderIdなど)
- バリデーションルールが必要な値(非空文字列、正の数など)
- 単位が重要な値(通貨、距離、重量など)
- セキュリティが重要な値(検証済みメール、サニタイズ済み文字列など)
次のステップ
- ドメイン駆動設計(DDD)と組み合わせた値オブジェクトの実装
- Zodなどのバリデーションライブラリとの統合
- 既存コードベースへの段階的導入戦略