本文へスキップ
ウェブエンジニア問題集
第14章

APIの入力とレスポンスをZodで検証する

約6分
この章の目次開く

フォームの次は、APIの境界です。ここには入ってくる方向と出ていく先から返ってくる方向の2つがあり、必要な考え方が違います。

方向何を検証するか失敗したときの振る舞い
リクエスト(受け取る)クライアントから届いた値400を返す
レスポンス(受け取る)外部APIから返ってきた値落とさず代替表示・リトライ

リクエストボディを検証する

Next.jsのRoute Handlerを例にします。request.json() の戻り値は any なので、そのまま使うと型の安全が切れます。

// app/api/users/route.ts
import { z } from 'zod';
 
const createUserSchema = z.object({
  name: z.string().trim().min(1),
  email: z.email(),
  age: z.number().int().min(0).optional(),
});
 
export async function POST(request: Request) {
  const body = await request.json(); // any
 
  const result = createUserSchema.safeParse(body);
  if (!result.success) {
    return Response.json(
      { message: '入力内容を確認してください', errors: z.flattenError(result.error).fieldErrors },
      { status: 400 },
    );
  }
 
  const user = await db.users.create(result.data); // ここから先は型が付いている
  return Response.json(user, { status: 201 });
}
ts
safeParse を通した後の result.data だけを下流に渡す。これが「境界で1回止める」の実装形です。

request.json() 自体が失敗する(JSONとして壊れている)可能性もあるので、実務では try で囲んでおきます。

let body: unknown;
try {
  body = await request.json();
} catch {
  return Response.json({ message: 'リクエストの形式が不正です' }, { status: 400 });
}
ts

レスポンスを信用しない

第1章で見たとおり、await res.json() as User は「そういうことにする」だけで、何も確かめていません。外部APIが仕様変更したり、エラー時に別の形を返したりすれば、そのまま壊れたデータが画面まで届きます。

const userSchema = z.object({ id: z.number(), name: z.string() });
 
async function fetchUser(id: number) {
  const res = await fetch(`https://api.example.com/users/${id}`);
  if (!res.ok) throw new Error(`APIエラー: ${res.status}`);
 
  const result = userSchema.safeParse(await res.json());
  if (!result.success) {
    // 想定と違う形。ログに残して、画面は代替表示に切り替える
    console.error('APIレスポンスが想定と異なります', z.prettifyError(result.error));
    return null;
  }
  return result.data;
}
ts
学習者学習者

自社のAPIでも検証した方がいいの? 形は分かっているのに。

先生先生

自社APIこそ変わるよ。フロントとバックが別々にデプロイされる以上、一時的に食い違う瞬間は必ずある。検証しておけば「画面が白くなる」代わりに「ここだけ表示できません」に留められるんだ。

失敗したときに落とすか、代替に切り替えるか

レスポンス検証で parse(例外)を使うと、APIの小さな変更でページ全体が落ちます。safeParse で受けて、その画面部分だけ縮退させる方が実務的です。

場面使い分け
表示用のデータsafeParse。失敗しても画面全体は保つ
決済・在庫など、間違うと実害が出るデータparse。壊れたまま処理を進めない
連絡を取っている人のイラスト

スキーマをどこに置くか

同じスキーマをクライアントとサーバーの両方で使うため、置き場所を決めておきます。

src/schemas/
  user.ts     ← createUserSchema, userSchema など
  post.ts
  • リクエスト用(作成・更新)とレスポンス用(表示)は別のスキーマになることが多い
  • 共通部分は1つ書いて、z.objectの章の .omit() や .partial() で派生させる
export const userSchema = z.object({ id: z.number(), name: z.string(), email: z.email() });
export const createUserSchema = userSchema.omit({ id: true }); // idはサーバーが採番
export const updateUserSchema = createUserSchema.partial(); // 部分更新
ts

よくあるハマりどころ

検証したのに元の変数を使っている

safeParse は新しい値を返します。body ではなく result.data を使ってください。.trim() や .default() の結果は戻り値にしか反映されません。

エラーオブジェクトをそのままレスポンスに載せる

z.flattenError() の fieldErrors のように、項目名とメッセージだけを返します。error 全体をJSONにすると内部構造が漏れます。

レスポンス検証に parse を使って画面が落ちる

外部APIの小さな変更が全画面のクラッシュになります。表示用データは safeParse で受けて縮退させます。

日付が z.date() で弾かれる

JSONにDate型は存在しません。z.iso.datetime() で文字列として検証するか、z.coerce.date() で変換します(デフォルト挙動の章を参照)。

ちゃんと使うためのポイント

  • request.json() の戻り値は any。必ず safeParse を通してから使う
  • 失敗したら z.flattenError() の fieldErrors を添えて400を返す
  • 自社APIであってもレスポンスは検証する。フロントとバックが食い違う瞬間は必ずある
  • 表示用データは safeParse で縮退、実害の出るデータは parse で停止
  • レスポンス用スキーマに strictObject を使わない

次の章では、記事やIssueで見かける古い書き方を読み解くための Zod 3から4への移行 を扱います。

参考リンク

TypeScriptクイズに挑戦するこの章で学んだ型と検証の知識を、4択クイズでアウトプットして定着させよう