DEV Community

myougaTheAxo
myougaTheAxo

Posted on • Originally published at zenn.dev

Claude CodeでTypeScript Branded Typesを設計する:名義型・型安全ID・単位ミス防止

TypeScriptの構造的型付け(structural typing)は柔軟性が高い反面、UserIdProductIdを誤って混在させても型エラーが出ないという落とし穴があります。Claude Codeを活用してBranded Types(名義型)を設計すると、コンパイル時に「IDの取り違え」や「通貨単位ミス」を防ぐことができます。本記事ではそのパターンを体系的に解説します。


CLAUDE.mdルール

このパターンを導入する際、プロジェクトのCLAUDE.mdに以下を記載しておくとClaude Codeが一貫した設計を維持してくれます。

## 型安全ルール
- IDフィールドには必ずBranded Typeを使用する(`string`直接使用禁止)
- 通貨は`Money<Currency>`型で表現し、JPYとUSDの混算をコンパイルエラーにする
- Zodスキーマでのバリデーション時は`.transform()`でBranded型へ変換する
- `as UserId`などの型アサーションはファクトリ関数内にのみ許可する
Enter fullscreen mode Exit fullscreen mode

このルールがあることで、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;
Enter fullscreen mode Exit fullscreen mode

これだけで、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 に代入不可
Enter fullscreen mode Exit fullscreen mode

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は加算不可
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

まとめ

  • Branded<T, Brand>パターンを使うと、構造的型付けの落とし穴(IDの取り違え)をコンパイル時に検出できる
  • Money<Currency>型で通貨単位の混算をゼロコストで防止でき、実行時エラーの温床を排除できる
  • Zodの.transform()と組み合わせることで、バリデーションと型変換を一箇所に集約し、コードの見通しが良くなる
  • CLAUDE.mdにルールを明記しておくとClaude Codeが一貫してBranded Typeを適用してくれるため、チーム全体の型安全性が自動的に維持される

さらに深く学ぶ

この記事で紹介したパターンを含む、Claude Codeのコードレビュー・設計支援プロンプトをまとめたCode Review Pack(¥980)をnoteで販売中です。

型安全設計・セキュリティレビュー・パフォーマンス分析など、現場で使えるプロンプト集です。ぜひご活用ください。

Code Review Pack ¥980 — note

Top comments (0)