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 | バリデーションの統合 | スキーマ定義とバリデーションが一体化し、二重管理が不要 |
2 | JSON との親和性 | プリミティブ型ベースなので 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 のブランド型を活用してみてください。型の恩恵を最大限に受けながら、保守性の高いコードベースを構築できるでしょう。
関連リンク
- article
Zod のブランド型(Branding)設計:メール・ULID・金額などの値オブジェクト化
- article
Zod クイックリファレンス:`string/number/boolean/date/enum/literal` 速見表
- article
Zod 導入最短ルート:yarn/pnpm/bun でのセットアップと型サポート
- article
Zod 全体像を図解で理解:プリミティブ → 合成 → 効果(transform/coerce/refine)の流れ
- article
Zod で非同期バリデーション(async)を実装する方法
- article
Zod で配列・オブジェクトを安全に扱うバリデーションテクニック
- article
LangChain ハイブリッド検索設計:BM25 +ベクトル+再ランキングで精度を底上げ
- article
Apollo スキーマの進化設計:非破壊変更・ディプレケーション・ロールアウト戦略
- article
Jotai ドメイン駆動設計:ユースケースと atom の境界を引く実践
- article
Zod のブランド型(Branding)設計:メール・ULID・金額などの値オブジェクト化
- article
Web Components の API 設計原則:属性 vs プロパティ vs メソッドの境界線
- article
Jest を Yarn PnP で動かす:ゼロ‐node_modules 時代の設定レシピ
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来