0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ZodのparseとsafeParse ── 使い分けの設計思想

0
Last updated at Posted at 2026-04-12

はじめに

Zodのバリデーションには parsesafeParse の 2 つのメソッドが存在します
どちらもスキーマに対してデータを検証するメソッドですが、失敗時の振る舞いがまったく異なります

この使い分けをエラーハンドリングの設計思想の側面から説明していこうと思います
この記事では、以下を通じて「なぜそちらを選ぶのか」を設計レベルで説明できるようになることを目指します

  • Result 型(Either 型)というエラーハンドリングのパターン
  • parsesafeParse の挙動の違い
  • Recoverable errors と Logic failures という分類
  • アーキテクチャの層ごとの使い分け

参考記事Result/Either派の人もドメインモデルの不変条件違反は例外で実装して良い理由

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. parsesafeParse の挙動を正確に理解する

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. 設計思想:エラーの「種類」で使い分ける

parsesafeParse をどう使い分けるかは、そのバリデーションで検出するエラーの種類で決まります

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. まとめ

parsesafeParse の使い分けは、単なるメソッド選択ではなく、エラーハンドリングの設計判断

「このバリデーション失敗は、想定内の出来事か? それともバグか?」

  • 想定内(Recoverable error)なら safeParse → エラーを値として返し、呼び出し元でハンドリング
  • バグ(Logic failure)なら parse → 例外を投げて、開発者に即座に気づかせる

この原則を守ることで、コードの意図が明確になり、バグの発見が早くなり、エラーメッセージも適切にユーザーに届くようになります

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?