はじめに
Zodのバリデーションには parse と safeParse の 2 つのメソッドが存在します
どちらもスキーマに対してデータを検証するメソッドですが、失敗時の振る舞いがまったく異なります
この使い分けをエラーハンドリングの設計思想の側面から説明していこうと思います
この記事では、以下を通じて「なぜそちらを選ぶのか」を設計レベルで説明できるようになることを目指します
- Result 型(Either 型)というエラーハンドリングのパターン
-
parseとsafeParseの挙動の違い - Recoverable errors と Logic failures という分類
- アーキテクチャの層ごとの使い分け
1. 前提知識:Result 型(Either 型)とは
エラーハンドリングには大きく 2 つのアプローチがあります
① 例外スローアプローチ
function divide(a: number, b: number): number {
if (b === 0) throw new Error('0で割ることはできません');
return a / b;
}
// 呼び出し側:try-catch が必要
try {
const result = divide(10, 0);
} catch (e) {
console.error(e.message);
}
問題点:
- 関数のシグネチャ(
numberを返す)から、エラーが発生しうることが読み取れない -
try-catchを書き忘れると、エラーが上位に伝播してアプリ全体がクラッシュする可能性がある
② Result 型 / Either 型アプローチ
成功と失敗を型として表現し、例外を投げないアプローチ
「Result 型」と「Either 型」は同じ思想の別名、どちらも成功値と失敗値をユニオン型で表現
Result 型(ok 等のフラグで判別):
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
function divide(a: number, b: number): Result<number, string> {
if (b === 0) return { ok: false, error: '0で割ることはできません' };
return { ok: true, value: a / b };
}
const result = divide(10, 0);
if (!result.ok) {
console.error(result.error);
} else {
console.log(result.value); // 型安全に値を取り出せる
}
Either 型(関数型プログラミング・Haskell 由来。Left = 失敗、Right = 成功):
-
Left── 失敗・エラー(「左に外れた」イメージ) -
Right── 成功・正常値(「正しい=Right」という語呂合わせ)
type Either<L, R> =
| { _tag: 'Left'; left: L } // 失敗
| { _tag: 'Right'; right: R }; // 成功
function divide(a: number, b: number): Either<string, number> {
if (b === 0) return { _tag: 'Left', left: '0で割ることはできません' };
return { _tag: 'Right', right: a / b };
}
const result = divide(10, 2);
if (result._tag === 'Left') {
console.error(result.left); // エラー処理
} else {
console.log(result.right); // 成功値を使う
}
両者の対応:
| Either | Result |
|---|---|
Left(失敗) |
{ ok: false; error } |
Right(成功) |
{ ok: true; value } |
利点:
- 関数のシグネチャから失敗しうることが明確
- TypeScript の型システムでエラーハンドリングが強制される(
result.valueに直接アクセスすると型エラー) - 例外が飛ばないので、処理の流れが予測しやすい
Zod の safeParse はまさに Result 型
Zod の safeParse が返す型は以下です
type SafeParseResult<T> =
| { success: true; data: T } // 成功: 検証済みデータ
| { success: false; error: ZodError }; // 失敗: エラー詳細
// → Result<T, ZodError> と本質的に同じ構造!
safeParse を選ぶということは 「エラーを例外ではなく値として扱う」 という設計判断をしていることになります
2. parse と safeParse の挙動を正確に理解する
2-1. parse ── 失敗すると例外を投げる
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
age: z.number().int().positive(),
});
// ✅ 成功時: パース済みの値が返る(型は z.infer<typeof UserSchema>)
const user = UserSchema.parse({
id: '550e8400-e29b-41d4-a716-446655440000',
name: 'Alice',
age: 30,
});
// ❌ 失敗時: ZodError をスロー
try {
UserSchema.parse({ id: 'not-uuid', name: '', age: -1 });
} catch (e) {
if (e instanceof z.ZodError) {
console.error(e.issues);
// [
// { code: "invalid_string", path: ["id"], message: "Invalid uuid" },
// { code: "too_small", path: ["name"], message: "String must contain at least 1 character(s)" },
// { code: "too_small", path: ["age"], message: "Number must be greater than 0" },
// ]
}
}
parse はすべてのバリデーションエラーを集約した ZodError を投げます
1 つ目のエラーで止まるのではなく、すべてチェックした結果をまとめて返却
2-2. safeParse ── 失敗しても例外を投げない
const result = UserSchema.safeParse({ id: 'not-uuid', name: '', age: -1 });
if (!result.success) {
// result.error は ZodError インスタンス
console.error(result.error.issues);
// → parse と同じエラー情報が取れる
// フォーム向けに整形する便利メソッドも使える
console.error(result.error.flatten());
// → { formErrors: [], fieldErrors: { id: [...], name: [...], age: [...] } }
} else {
// result.data は型安全にアクセスできる
console.log(result.data.name);
}
2-3. 型レベルの違いまとめ
| メソッド | 戻り値の型 | 失敗時の挙動 |
|---|---|---|
parse(data) |
T |
ZodError をスロー |
safeParse(data) |
{ success: true; data: T } | { success: false; error: ZodError } |
例外なし。戻り値で判断 |
parseAsync(data) |
Promise<T> |
ZodError をスロー |
safeParseAsync(data) |
Promise<SafeParseReturnType<T>> |
例外なし。戻り値で判断 |
3. 設計思想:エラーの「種類」で使い分ける
parse と safeParse をどう使い分けるかは、そのバリデーションで検出するエラーの種類で決まります
Recoverable errors(回復可能なエラー)
- 定義:発生することが予測可能で、呼び出し元でハンドリングが必要なエラー
- 特徴:起きても「そういうこともあるよね」で済む。ユーザーに修正を促したり、別の処理にフォールバックする
- 例:ユーザー入力のバリデーション失敗、外部 API のレスポンス不正、CSV のフォーマットエラー
→ safeParseを使う(エラーを値として扱い、呼び出し元に判断を委ねる)
Logic failures(ロジックの失敗)
- 定義:発生してはいけないエラー。起きた時点で実装ミス・バグを意味する
- 特徴:起きたら「これはおかしい、コードを直さないと」となるべき
- 例:ドメインモデルの不変条件違反、DB から取得したデータの構造崩壊
→ parse を使う(例外を投げて、開発者へ即座に気づかせる)
| エラーの種類 | 対応メソッド | なぜ? |
|---|---|---|
| Recoverable error(回復可能) | safeParse | エラーは想定内。呼び出し元で対処 |
| Logic failure(ロジックの失敗) | parse | エラーはバグ。即座に気づくべき |
4. アーキテクチャの層で考える ── Always Valid Domain Model
この使い分けは、アーキテクチャの層構造から考えると分かりやすいです
外側の層:safeParse で「信頼できないデータ」を受け止める
外側の層(Controller、API ハンドラ、フォームバリデーション)に入ってくるデータは信頼できない値として扱う
ユーザーが間違えることも、外部APIが仕様変更することも、普通に起こりうること
// 🌐 API ハンドラでの入力バリデーション
const CreateTaskRequestSchema = z.object({
title: z.string().min(1, 'タイトルは必須です').max(100, '100文字以内で入力してください'),
description: z.string().max(1000).optional(),
dueDate: z.coerce.date().min(new Date(), '過去の日付は指定できません'),
priority: z.enum(['low', 'medium', 'high']),
});
type CreateTaskRequest = z.infer<typeof CreateTaskRequestSchema>;
function handleCreateTask(body: unknown) {
const result = CreateTaskRequestSchema.safeParse(body);
if (!result.success) {
// ユーザーに何が間違っているか教える ← Recoverable error
return {
status: 400,
errors: result.error.flatten().fieldErrors,
// → { title: ["タイトルは必須です"], dueDate: ["過去の日付は指定できません"], ... }
};
}
// ここから先、result.data は型安全かつ検証済み
return taskService.create(result.data);
}
内側の層:parse で「不変条件」を守る
ドメイン層に到達するデータは、外側の層で検証済みのはず
ここに不正なデータが来ること自体がバグなので、例外を投げて開発者に知らせるべき
// 🏗️ ドメインモデル:DateRange(開始日 < 終了日 を不変条件とする)
const DateRangeSchema = z
.object({
from: z.coerce.date(),
to: z.coerce.date(),
})
.refine(({ from, to }) => from < to, {
message: '開始日は終了日より前でなければならない',
});
type DateRange = z.infer<typeof DateRangeSchema>;
function createDateRange(from: Date, to: Date): DateRange {
// ここに不正値が来ること自体がバグ → 例外でいい
return DateRangeSchema.parse({ from, to });
}
これが Always Valid Domain Model(常に有効なドメインモデル)という考え方です
ドメインモデルは「作られた時点で必ず有効」であり、不正な状態のインスタンスは存在しえない
5. アンチパターン:よくある誤用
❌ アンチパターン 1:ドメイン層で safeParse してエラーを握り潰す
function hydrateTask(row: unknown): Task | null {
const result = TaskSchema.safeParse(row);
if (!result.success) {
return null; // ← バグを握り潰している!
}
return result.data;
}
なぜダメか?
DB のデータ不整合やスキーマ変更によるバグが静かに無視されます
null が返ることで呼び出し元にさらなるハンドリングが必要になり、根本原因の発見が遅れます
❌ アンチパターン 2:入力バリデーションで parse を使って try-catch する
function handleSignup(body: unknown) {
try {
const input = SignupSchema.parse(body); // ← ユーザー入力ミスで例外が飛ぶ
return createUser(input);
} catch (e) {
if (e instanceof z.ZodError) {
return { status: 400, errors: e.issues };
}
throw e;
}
}
なぜダメか?
動作はするが、設計意図が曖昧になります
- ユーザーが間違えるたびに例外が発生 → パフォーマンスの微妙な低下
- グローバルエラーハンドラに依存しがちな設計になる
- エラーの意味(バグ vs ユーザー入力ミス)が try-catch の中に混在して判別しにくくなる
✅ 正しいパターン
// 外側:safeParse でユーザーに寄り添う
function handleSignup(body: unknown) {
const result = SignupSchema.safeParse(body);
if (!result.success) {
return { status: 400, errors: result.error.flatten().fieldErrors };
}
return createUser(result.data);
}
// 内側:parse でバグを早期発見
function createUser(input: SignupInput): User {
return UserSchema.parse({
id: crypto.randomUUID(),
...input,
createdAt: new Date(),
});
}
6. 判断フローチャート
バリデーションを書くときに迷ったら、以下のフローで判断できます
このバリデーションで検出するエラーは?
│
├─ ユーザー入力 / 外部 API / フォームデータ / CSV 読み込み
│ │
│ └─ 「不正な値が来ることは想定内」
│ └─ safeParse ✅(エラーを値として返す)
│
├─ 環境変数 / 設定ファイル
│ │
│ └─ 「不正 = 設定ミス = 起動させない」
│ └─ parse ✅(例外でアプリを止める)
│
├─ DB から取得したデータ
│ │
│ └─ 「不正 = データ不整合 = バグ」
│ └─ parse ✅(例外で開発者に知らせる)
│
└─ ドメインモデルのコンストラクタ / ファクトリ
│
└─ 「不正 = 不変条件違反 = バグ」
└─ parse ✅(例外で早期発見)
早見表
| 使う場所 | 使うメソッド | 理由 |
|---|---|---|
| HTTP リクエストボディ | safeParse |
ユーザー入力ミスは Recoverable error |
| フォームバリデーション | safeParse |
同上 |
| 外部 API レスポンス | safeParse |
外部の変化に対応できるように |
| CSV / ファイル読み込み | safeParse |
データ品質は保証されない |
| DB からのモデル復元 | parse |
不整合はバグ。開発者に知らせるべき |
| ドメインモデルのコンストラクタ | parse |
不変条件違反は Logic failure |
| 環境変数の検証 | parse |
設定ミスはアプリを起動させない |
| テストデータの組み立て | parse |
データが間違っていたらテストを直す |
7. まとめ
parse と safeParse の使い分けは、単なるメソッド選択ではなく、エラーハンドリングの設計判断
「このバリデーション失敗は、想定内の出来事か? それともバグか?」
-
想定内(Recoverable error)なら
safeParse→ エラーを値として返し、呼び出し元でハンドリング -
バグ(Logic failure)なら
parse→ 例外を投げて、開発者に即座に気づかせる
この原則を守ることで、コードの意図が明確になり、バグの発見が早くなり、エラーメッセージも適切にユーザーに届くようになります