TypeScriptの構造的型付け(structural typing)は柔軟性が高い反面、UserIdとProductIdを誤って混在させても型エラーが出ないという落とし穴があります。Claude Codeを活用してBranded Types(名義型)を設計すると、コンパイル時に「IDの取り違え」や「通貨単位ミス」を防ぐことができます。本記事ではそのパターンを体系的に解説します。
CLAUDE.mdルール
このパターンを導入する際、プロジェクトのCLAUDE.mdに以下を記載しておくとClaude Codeが一貫した設計を維持してくれます。
## 型安全ルール
- IDフィールドには必ずBranded Typeを使用する(`string`直接使用禁止)
- 通貨は`Money<Currency>`型で表現し、JPYとUSDの混算をコンパイルエラーにする
- Zodスキーマでのバリデーション時は`.transform()`でBranded型へ変換する
- `as UserId`などの型アサーションはファクトリ関数内にのみ許可する
このルールがあることで、Claude Codeがコード生成時に自動的にBranded Typeを適用します。
Branded<T, Brand> ファントム型パターン
基本的なBranded Typeの実装から始めましょう。
// types/branded.ts
declare const __brand: unique symbol;
export type Brand<T, TBrand extends string> = T & {
readonly [__brand]: TBrand;
};
// ユーザーID・プロダクトID・注文IDの定義
export type UserId = Brand<string, 'UserId'>;
export type ProductId = Brand<string, 'ProductId'>;
export type OrderId = Brand<string, 'OrderId'>;
// ファクトリ関数(型アサーションはここだけに封じ込める)
export const UserId = (id: string): UserId => id as UserId;
export const ProductId = (id: string): ProductId => id as ProductId;
export const OrderId = (id: string): OrderId => id as OrderId;
これだけで、getUserById(productId)のような誤った呼び出しがコンパイルエラーになります。
// services/user.service.ts
async function getUserById(id: UserId): Promise<User> {
return db.users.findUnique({ where: { id } });
}
const uid = UserId('user-123');
const pid = ProductId('prod-456');
getUserById(uid); // OK
getUserById(pid); // コンパイルエラー! ProductId は UserId に代入不可
Money<Currency> で通貨単位ミスを防ぐ
金額計算で最も危険なのは、JPY建ての金額とUSD建ての金額を足してしまうケースです。
// types/money.ts
type Currency = 'JPY' | 'USD' | 'EUR';
export type Money<C extends Currency> = Brand<number, `Money_${C}`>;
export const Money = {
jpy: (amount: number): Money<'JPY'> => amount as Money<'JPY'>,
usd: (amount: number): Money<'USD'> => amount as Money<'USD'>,
eur: (amount: number): Money<'EUR'> => amount as Money<'EUR'>,
// 同一通貨内での加算のみ許可
add: <C extends Currency>(a: Money<C>, b: Money<C>): Money<C> =>
(a + b) as Money<C>,
// フォーマット
format: <C extends Currency>(amount: Money<C>, currency: C): string =>
new Intl.NumberFormat('ja-JP', { style: 'currency', currency }).format(amount),
};
// 使用例
const price = Money.jpy(1980);
const tax = Money.jpy(198);
const total = Money.add(price, tax); // OK: Money<'JPY'>
const usdPrice = Money.usd(14.99);
Money.add(price, usdPrice); // コンパイルエラー! JPYとUSDは加算不可
ZodとのBranded Type統合
APIのリクエストボディをバリデーションしながらBranded Typeに変換するパターンです。
// schemas/order.schema.ts
import { z } from 'zod';
import { UserId, ProductId, OrderId, Money } from '../types';
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export const CreateOrderSchema = z.object({
userId: z
.string()
.regex(uuidRegex, 'Invalid UUID format')
.transform((val) => UserId(val)), // string → UserId
productId: z
.string()
.regex(uuidRegex, 'Invalid UUID format')
.transform((val) => ProductId(val)), // string → ProductId
amount: z
.number()
.positive('Amount must be positive')
.transform((val) => Money.jpy(val)), // number → Money<'JPY'>
});
export type CreateOrderInput = z.infer<typeof CreateOrderSchema>;
// {
// userId: UserId
// productId: ProductId
// amount: Money<'JPY'>
// }
// Expressルーターでの使用
app.post('/orders', async (req, res) => {
const result = CreateOrderSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
// result.data.userId は UserId 型が保証されている
const order = await orderService.create(result.data);
res.status(201).json(order);
});
まとめ
- Branded<T, Brand>パターンを使うと、構造的型付けの落とし穴(IDの取り違え)をコンパイル時に検出できる
- Money<Currency>型で通貨単位の混算をゼロコストで防止でき、実行時エラーの温床を排除できる
-
Zodの
.transform()と組み合わせることで、バリデーションと型変換を一箇所に集約し、コードの見通しが良くなる - CLAUDE.mdにルールを明記しておくとClaude Codeが一貫してBranded Typeを適用してくれるため、チーム全体の型安全性が自動的に維持される
さらに深く学ぶ
この記事で紹介したパターンを含む、Claude Codeのコードレビュー・設計支援プロンプトをまとめたCode Review Pack(¥980)をnoteで販売中です。
型安全設計・セキュリティレビュー・パフォーマンス分析など、現場で使えるプロンプト集です。ぜひご活用ください。
Top comments (0)