T-CREATOR

Zod のブランド型(Branding)設計:メール・ULID・金額などの値オブジェクト化

Zod のブランド型(Branding)設計:メール・ULID・金額などの値オブジェクト化

TypeScript で開発していると、文字列や数値の型だけでは区別できないデータを扱う機会が多くありますよね。メールアドレス、ULID、金額など、すべて string や number として扱えますが、本来はそれぞれ異なる意味を持つ値です。

こうした値を型レベルで明確に区別し、誤った代入や混在を防ぐ仕組みが「値オブジェクト」の考え方です。Zod のブランド型(Branded Types)を活用すれば、バリデーションと型の安全性を両立した値オブジェクト設計が実現できます。この記事では、Zod のブランド型を使った実践的な値オブジェクト化の方法を、具体例とともに解説していきます。

背景

TypeScript の型システムは強力ですが、プリミティブ型に対しては構造的型付け(Structural Typing)のため、同じ string 型同士であれば互換性があると判断されます。

たとえば、以下のようなコードは TypeScript のコンパイラを通過してしまいます。

typescripttype Email = string;
type UserId = string;

const email: Email = 'user@example.com';
const userId: UserId = email; // エラーにならない

この例では、メールアドレスとユーザー ID が混在してしまっても、コンパイラは検知できません。実行時にバグとして顕在化する可能性があるわけです。

こうした問題を防ぐため、値オブジェクト(Value Object)パターンが用いられます。値オブジェクトとは、ドメイン駆動設計(DDD)で提唱される概念で、値そのものに意味を持たせ、不変性とバリデーションを備えたオブジェクトとして扱います。

従来の TypeScript では、クラスを使って値オブジェクトを実装するのが一般的でした。

typescriptclass Email {
  private constructor(private readonly value: string) {}

  static create(value: string): Email {
    // バリデーション処理
    if (!value.includes('@')) {
      throw new Error('Invalid email format');
    }
    return new Email(value);
  }

  getValue(): string {
    return this.value;
  }
}

しかし、この方法にはいくつかの課題があります。

課題

クラスベースの値オブジェクト実装には、以下のような問題点があります。

1. JSON シリアライズの複雑さ

API のレスポンスや外部システムとのデータのやり取りでは、JSON 形式が標準です。クラスインスタンスは JSON.stringify で直接シリアライズできますが、デシリアライズ時には特別な処理が必要になります。

typescript// シリアライズ
const email = Email.create('user@example.com');
const json = JSON.stringify({ email }); // クラスインスタンスがそのまま文字列化される

// デシリアライズ
const parsed = JSON.parse(json);
// parsed.email は普通のオブジェクト、Email クラスのメソッドは使えない

この問題を解決するには、カスタムのデシリアライザを実装する必要があり、コードが煩雑になります。

2. ボイラープレートコードの増加

値オブジェクトごとにクラス定義、バリデーション、ゲッターメソッドを書く必要があり、プロジェクト内で値オブジェクトが増えるほどコード量が肥大化します。

typescript// 各値オブジェクトに対して、似たような構造のクラスを定義
class Email {
  /* ... */
}
class UserId {
  /* ... */
}
class Price {
  /* ... */
}

3. バリデーションとの二重管理

フロントエンドとバックエンドで同じバリデーションロジックを実装したり、スキーマ定義とクラスの整合性を手動で保つ必要があったりと、保守性が低下します。

以下の図は、クラスベースの値オブジェクトにおける課題の構造を示しています。

mermaidflowchart TD
  A["クラスベース<br/>値オブジェクト"] --> B["JSON シリアライズ<br/>の複雑さ"]
  A --> C["ボイラープレート<br/>コードの増加"]
  A --> D["バリデーション<br/>二重管理"]
  B --> E["カスタム<br/>デシリアライザが必要"]
  C --> F["コード量の肥大化"]
  D --> G["保守性の低下"]

これらの課題を解決するのが、Zod のブランド型を活用した値オブジェクト設計です。

解決策

Zod のブランド型(.brand())を使うことで、バリデーションと型の区別を簡潔に実現できます。ブランド型とは、型にユニークなマーカー(ブランド)を付与することで、構造的には同じでも異なる型として扱える仕組みです。

ブランド型の基本構造

まず、Zod でブランド型を作成する基本的な方法を見てみましょう。

typescriptimport { z } from 'zod';

// 通常の文字列スキーマにブランドを付与
const EmailSchema = z.string().email().brand('Email');
type Email = z.infer<typeof EmailSchema>;

このコードにより、Email 型は単なる string ではなく、string & { __brand: "Email" } という形式の型になります。

次に、パース関数を定義します。

typescript// バリデーション付きパース関数
function parseEmail(value: string): Email {
  return EmailSchema.parse(value);
}

この parseEmail 関数は、入力された文字列がメールアドレスとして妥当かを検証し、成功すれば Email 型の値として返します。

ブランド型の効果

ブランド型により、以下のような型安全性が得られます。

typescripttype Email = z.infer<typeof EmailSchema>;
type UserId = z.infer<typeof UserIdSchema>;

const email: Email = parseEmail('user@example.com');
const userId: UserId = parseUserId(
  '01ARZ3NDEKTSV4RRFFQ69G5FAV'
);

// これはコンパイルエラーになる
const mixedValue: UserId = email;
// Type 'string & { __brand: "Email" }' is not assignable to type 'string & { __brand: "UserId" }'

このように、構造的には同じ string 型でも、ブランドが異なるため代入できません。コンパイル時に型の誤りを検出できるわけです。

Zod ブランド型の利点

Zod のブランド型を使った値オブジェクト設計には、次のようなメリットがあります。

#項目説明
1バリデーションの統合スキーマ定義とバリデーションが一体化し、二重管理が不要
2JSON との親和性プリミティブ型ベースなので JSON シリアライズが自然
3コードの簡潔性クラス定義不要で、数行のコードで値オブジェクトを実現
4型推論の活用z.infer により型定義を自動生成
5再利用性スキーマを組み合わせて複雑な型を構築可能

以下の図は、Zod ブランド型による解決アプローチを示しています。

mermaidflowchart LR
  input["文字列入力"] --> schema["Zod スキーマ<br/>+ バリデーション"]
  schema -->|成功| brand["ブランド型<br/>付与"]
  schema -->|失敗| error["ZodError"]
  brand --> output["型安全な<br/>値オブジェクト"]
  output --> api["API / JSON<br/>シリアライズ"]

このフローにより、入力値のバリデーションと型の区別が一貫して管理できます。

次の章では、実際のユースケースに沿った具体例を見ていきましょう。

具体例

ここからは、実務でよく使われる値オブジェクトの実装例を、Zod のブランド型を使って紹介します。

メールアドレス(Email)

メールアドレスは、最も基本的な値オブジェクトの一つです。Zod には標準で .email() バリデータが用意されています。

typescriptimport { z } from 'zod';

// メールアドレススキーマの定義
const EmailSchema = z
  .string()
  .email('有効なメールアドレスを入力してください')
  .brand('Email');

// 型の推論
type Email = z.infer<typeof EmailSchema>;

次に、パース関数とセーフパース関数を作成します。

typescript// 厳格なパース(エラー時は例外を投げる)
function parseEmail(value: string): Email {
  return EmailSchema.parse(value);
}

// セーフパース(エラー時は Result 型で返す)
function safeParseEmail(
  value: string
): z.SafeParseReturnType<string, Email> {
  return EmailSchema.safeParse(value);
}

実際の利用例は以下の通りです。

typescript// 成功例
const email = parseEmail('user@example.com');
console.log(email); // "user@example.com"

// 失敗例(例外が発生)
try {
  parseEmail('invalid-email');
} catch (error) {
  if (error instanceof z.ZodError) {
    console.error(error.errors);
    // [{ code: 'invalid_string', validation: 'email', message: '有効なメールアドレスを入力してください', ... }]
  }
}

セーフパースを使った場合は、例外ではなく Result 型でエラーハンドリングができます。

typescriptconst result = safeParseEmail('invalid-email');

if (result.success) {
  // 成功時は result.data に Email 型の値が入る
  const email: Email = result.data;
} else {
  // 失敗時は result.error に ZodError が入る
  console.error(result.error.errors);
}

ULID(Universally Unique Lexicographically Sortable Identifier)

ULID は、UUID に代わる識別子として注目されています。タイムスタンプベースでソート可能、かつ URL セーフな文字列です。

ULID の形式は、26 文字の英数字(Crockford's Base32)です。この形式を正規表現でバリデーションします。

typescript// ULID のフォーマット:26文字の英数字(0-9, A-Z, 小文字なし)
const ULID_REGEX = /^[0-9A-HJKMNP-TV-Z]{26}$/;

const UlidSchema = z
  .string()
  .regex(ULID_REGEX, '有効なULID形式ではありません')
  .brand('Ulid');

type Ulid = z.infer<typeof UlidSchema>;

パース関数を定義します。

typescriptfunction parseUlid(value: string): Ulid {
  return UlidSchema.parse(value);
}

function safeParseUlid(
  value: string
): z.SafeParseReturnType<string, Ulid> {
  return UlidSchema.safeParse(value);
}

実際の利用例です。

typescript// 成功例
const ulid = parseUlid('01ARZ3NDEKTSV4RRFFQ69G5FAV');
console.log(ulid); // "01ARZ3NDEKTSV4RRFFQ69G5FAV"

// 失敗例
const result = safeParseUlid('invalid-ulid');
if (!result.success) {
  console.error(result.error.errors);
  // [{ code: 'invalid_string', validation: 'regex', message: '有効なULID形式ではありません', ... }]
}

ULID 生成ライブラリ(例:ulid)と組み合わせると、生成と型安全性を両立できます。

typescriptimport { ulid } from 'ulid';

// ULID を生成してブランド型として扱う
function generateUlid(): Ulid {
  const generated = ulid();
  return parseUlid(generated); // バリデーション済みのブランド型として返す
}

金額(Price)

金額は、負の値を許可しない、小数点以下の桁数を制限するなど、ビジネスロジックに応じたバリデーションが必要です。

まず、基本的な金額スキーマを定義します。

typescript// 0以上の整数のみを許可
const PriceSchema = z
  .number()
  .int('金額は整数である必要があります')
  .nonnegative('金額は0以上である必要があります')
  .brand('Price');

type Price = z.infer<typeof PriceSchema>;

パース関数を作成します。

typescriptfunction parsePrice(value: number): Price {
  return PriceSchema.parse(value);
}

function safeParsePrice(
  value: number
): z.SafeParseReturnType<number, Price> {
  return PriceSchema.safeParse(value);
}

実際の利用例です。

typescript// 成功例
const price = parsePrice(1000);
console.log(price); // 1000

// 失敗例:負の値
try {
  parsePrice(-100);
} catch (error) {
  if (error instanceof z.ZodError) {
    console.error(error.errors);
    // [{ code: 'too_small', minimum: 0, type: 'number', message: '金額は0以上である必要があります', ... }]
  }
}

より高度な例として、最大値や消費税計算を含む金額スキーマを作成してみましょう。

typescript// 上限付きの金額スキーマ(例:100万円まで)
const LimitedPriceSchema = z
  .number()
  .int()
  .nonnegative()
  .max(1_000_000, '金額は100万円以下である必要があります')
  .brand('LimitedPrice');

type LimitedPrice = z.infer<typeof LimitedPriceSchema>;

金額に対する演算関数も型安全に実装できます。

typescript// 消費税を計算する関数(型安全性を保ちつつ計算)
function calculateTax(
  price: Price,
  taxRate: number = 0.1
): number {
  return Math.floor(price * taxRate);
}

// 税込み金額を計算
function calculateTotalPrice(
  price: Price,
  taxRate: number = 0.1
): Price {
  const total = price + calculateTax(price, taxRate);
  return parsePrice(total); // 結果も Price 型として返す
}

利用例は以下の通りです。

typescriptconst basePrice = parsePrice(1000);
const tax = calculateTax(basePrice); // 100
const totalPrice = calculateTotalPrice(basePrice); // 1100(Price型)

ユーザー名(Username)

ユーザー名は、長さ制限や使用可能文字の制約があることが一般的です。

typescript// 3文字以上20文字以下、英数字とアンダースコアのみ許可
const UsernameSchema = z
  .string()
  .min(3, 'ユーザー名は3文字以上である必要があります')
  .max(20, 'ユーザー名は20文字以内である必要があります')
  .regex(
    /^[a-zA-Z0-9_]+$/,
    'ユーザー名は英数字とアンダースコアのみ使用できます'
  )
  .brand('Username');

type Username = z.infer<typeof UsernameSchema>;

パース関数を定義します。

typescriptfunction parseUsername(value: string): Username {
  return UsernameSchema.parse(value);
}

function safeParseUsername(
  value: string
): z.SafeParseReturnType<string, Username> {
  return UsernameSchema.safeParse(value);
}

利用例です。

typescript// 成功例
const username = parseUsername('user_123');
console.log(username); // "user_123"

// 失敗例:短すぎる
const result = safeParseUsername('ab');
if (!result.success) {
  console.error(result.error.errors);
  // [{ code: 'too_small', minimum: 3, type: 'string', message: 'ユーザー名は3文字以上である必要があります', ... }]
}

複合型:ユーザーオブジェクト

これまでに定義した値オブジェクトを組み合わせて、複雑なオブジェクトを型安全に構築できます。

typescript// ユーザーオブジェクトのスキーマ
const UserSchema = z.object({
  id: UlidSchema,
  username: UsernameSchema,
  email: EmailSchema,
  createdAt: z.date(),
});

type User = z.infer<typeof UserSchema>;

パース関数を定義します。

typescriptfunction parseUser(data: unknown): User {
  return UserSchema.parse(data);
}

実際の利用例です。

typescript// API レスポンスをパース
const apiResponse = {
  id: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
  username: 'john_doe',
  email: 'john@example.com',
  createdAt: new Date('2025-01-01'),
};

const user = parseUser(apiResponse);
// user.id は Ulid 型
// user.username は Username 型
// user.email は Email 型

以下の図は、各値オブジェクトがどのように組み合わされてユーザーオブジェクトを構成するかを示しています。

mermaidflowchart TD
  A["User オブジェクト"] --> B["id: Ulid"]
  A --> C["username: Username"]
  A --> D["email: Email"]
  A --> E["createdAt: Date"]
  B --> F["UlidSchema<br/>バリデーション"]
  C --> G["UsernameSchema<br/>バリデーション"]
  D --> H["EmailSchema<br/>バリデーション"]

ヘルパー関数の作成

ブランド型を扱う際の共通処理をヘルパー関数として整理すると、コードの再利用性が高まります。

typescript// 型ガード関数を生成するヘルパー
function createTypeGuard<T extends z.ZodTypeAny>(
  schema: T
) {
  return (value: unknown): value is z.infer<T> => {
    return schema.safeParse(value).success;
  };
}

// Email 型ガード
const isEmail = createTypeGuard(EmailSchema);

// 使用例
const maybeEmail: unknown = 'user@example.com';
if (isEmail(maybeEmail)) {
  // この中では maybeEmail は Email 型として扱える
  console.log(maybeEmail.toUpperCase());
}

配列のパース関数も便利です。

typescript// 配列のパース関数を生成
function createArrayParser<T extends z.ZodTypeAny>(
  schema: T
) {
  const arraySchema = z.array(schema);
  return (values: unknown[]): Array<z.infer<T>> => {
    return arraySchema.parse(values);
  };
}

// メールアドレス配列のパーサー
const parseEmailArray = createArrayParser(EmailSchema);

const emails = parseEmailArray([
  'user1@example.com',
  'user2@example.com',
]);
// emails は Email[] 型

フォーム入力との統合

React のフォームライブラリ(例:React Hook Form)と組み合わせると、フォームバリデーションとブランド型を統合できます。

typescriptimport { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

// フォームスキーマ
const SignUpFormSchema = z.object({
  username: UsernameSchema,
  email: EmailSchema,
  password: z
    .string()
    .min(8, 'パスワードは8文字以上である必要があります'),
});

type SignUpFormData = z.infer<typeof SignUpFormSchema>;

フォームコンポーネントの実装例です。

typescriptfunction SignUpForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignUpFormData>({
    resolver: zodResolver(SignUpFormSchema),
  });

  const onSubmit = (data: SignUpFormData) => {
    // data.username は Username 型
    // data.email は Email 型
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('username')} />
      {errors.username && (
        <span>{errors.username.message}</span>
      )}

      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type='password' {...register('password')} />
      {errors.password && (
        <span>{errors.password.message}</span>
      )}

      <button type='submit'>登録</button>
    </form>
  );
}

この実装により、フォーム送信時には既にバリデーション済みのブランド型として値を扱えます。

以下の図は、フォーム入力からブランド型への変換フローを示しています。

mermaidsequenceDiagram
  participant User as ユーザー
  participant Form as フォーム
  participant Zod as Zod Resolver
  participant Handler as Submit Handler

  User->>Form: 入力
  Form->>Zod: バリデーション要求
  Zod->>Zod: スキーマ検証
  alt バリデーション成功
    Zod->>Handler: ブランド型データ
    Handler->>Handler: 型安全な処理
  else バリデーション失敗
    Zod->>Form: エラー表示
    Form->>User: エラーメッセージ
  end

まとめ

Zod のブランド型を活用することで、TypeScript における値オブジェクト設計がシンプルかつ強力になります。

主なポイントは以下の通りです。

  • 型安全性の向上:構造的には同じプリミティブ型でも、ブランドにより明確に区別できます
  • バリデーションの統合:スキーマ定義とバリデーションが一体化し、コードの重複が削減されます
  • JSON との親和性:プリミティブ型ベースなので、API 通信やデータ永続化がスムーズです
  • コードの簡潔性:クラス定義不要で、数行のコードで値オブジェクトを実現できます
  • 再利用性:スキーマを組み合わせて、複雑なオブジェクト構造を構築できます

メールアドレス、ULID、金額、ユーザー名など、実務で頻出する値オブジェクトを Zod のブランド型で実装することで、型安全性とバリデーションを両立した堅牢なアプリケーション設計が可能になります。

フォームバリデーションや API レスポンスのパース、ドメインモデルの構築など、幅広い場面で Zod のブランド型を活用してみてください。型の恩恵を最大限に受けながら、保守性の高いコードベースを構築できるでしょう。

関連リンク