Zod でAPIレスポンスのバリデーションを整えたらエラー周りがキレイになった話

外部APIのレスポンスを as でキャストしていたコードに Zod を導入したところ、実行時エラーが減り、型の信頼度がかなり上がりました。その手順と感想をまとめます。

🙌 結論から

API レスポンスの型を as でキャストしていたコードに Zod を導入したら、「型は正しいはずなのに実行時に undefined になった」という事故がかなり減りました!

Zod はスキーマを書くだけで TypeScript の型も自動で推論されるので、型定義を二重に書く必要がありません。

外部 API のレスポンスや localStorage のデータなど、「型は合っているつもりだったが実際には違った」という境界に使うと、かなり効果を感じられます。

💡 以前の問題

仕事で使っていた TypeScript プロジェクトでは、外部 API からデータを取得するときにこういうコードを書いていました。

const res = await fetch('/api/user')
const data = await res.json() as User

TypeScript 的にはエラーが出ませんが、as はあくまでコンパイラへの「信じてくれ」という命令です。

実際のレスポンスに name フィールドがなかった場合でも、TypeScript はそれを検知できません(´・ω・`)

あるとき、本番環境で API のレスポンス構造が変わったことに気づかず、data.profile.email を参照する箇所で Cannot read properties of undefined エラーが出たことがありました。

TypeScript を使っているのに型の恩恵を活かせていない、という状況…

👀 Zod でバリデーションを書く

Zod はスキーマ定義から TypeScript の型を自動で生成できるライブラリです。

npm install zod
import { z } from 'zod'

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  profile: z.object({
    bio: z.string().nullable(),
  }),
})

type User = z.infer<typeof UserSchema>

z.infer<typeof UserSchema> で型が自動生成されるので、型定義と実行時チェックを同時に管理できます!

実際にバリデーションをかけるときは parsesafeParse を使います。

const res = await fetch('/api/user')
const raw = await res.json()

const result = UserSchema.safeParse(raw)

if (!result.success) {
  console.error(result.error.format())
  throw new Error('APIレスポンスの形式が不正です')
}

const user = result.data // この時点で User 型が保証される

safeParse は例外を投げずに { success, data, error } を返してくれるので、エラーハンドリングがしやすいです(^^)

parse の方はバリデーション失敗時に例外を投げるので、try-catch で扱いたい場面に向いています。

✨ 導入してみた感想

道入して一番ありがたかったのは、APIの構造変更に即座に気づけるようになったことです。

以前は変更に気づかず UI が壊れてから発覚していたのが、リクエスト時点でバリデーションエラーとして検知できるようになりました。

.nullable().optional() を明示的に書く作業が、ドキュメントの代わりにもなっているので、後からコードを読む人にも伝わりやすくなりました🔥可読性が増したのはチームにとってもいいことですね^^

一点だけ注意が必要なのは、レスポンスの型が複雑な場合にスキーマの記述量がそれなりに増えることです。

特にネストが深い場合は .object() の中に .object() を入れる形になり、最初は少し読みにくさを感じるかも・_・;)

ただ、スキーマを細かく分割して z.object({ profile: ProfileSchema }) のように組み合わせると、見通しが良くなります!

🆙 Zod v4に上げてわかった変更点

しばらく運用してから Zod v4 が出ていることに気づいて、アップグレードしてみました。

一番驚いたのがパフォーマンスです。公式ベンチマークによると、文字列のパースが14倍、配列が7倍、オブジェクトが6.5倍速くなっているとのことで、私のプロジェクトでも大きめのレスポンスをパースするAPIで体感できるレベルの違いがありました(・∀・)

書き方にもいくつか変更が入っていて、私が実際に直したのはこのあたりです。

// v3 の書き方
const schema = z.object({
  email: z.string().email(),
});

// v4 の書き方(フォーマット系がトップレベル関数に)
const schema = z.object({
  email: z.email(),
});

.email() のようなフォーマット系のバリデーションが、文字列メソッドのチェーンからトップレベルの関数(z.email())に変わっています。

エラーの整形ヘルパーも変わっていて、以前は error.format() としていたところが z.treeifyError(error) のような独立した関数になりました。

// v3
console.error(result.error.format());

// v4
console.error(z.treeifyError(result.error));

ありがたいのは、Zod v3時代のAPIも警告付きで動き続ける設計になっていることです。

私は移行期間中、zod/v4 という新しいimportパスを使って、ファイルごとに少しずつ書き換えていく形で対応しました。

一気に全部書き換えるのは怖かったんですが、この「並行運用できる」仕組みのおかげで、テストを見ながら安全に移行できたのが助かりました!

パパ系Webエンジニア〜愛用ガジェット一覧 🛍️

この記事を書いている作業環境のガジェットを楽天ROOMにまとめています ✨ 在宅エンジニアのデスク作りの参考にどうぞ 🙌

👍 まとめ

Zod は 「TypeScript の型は合っているが、実行時に崩れる」という境界に使うと、けっこう便利です!s

外部 API のレスポンスはその典型例で、as でキャストするよりも safeParse で検証する方が、実際の安全性がかなり上がります。

型定義とスキーマを1か所で管理できるのも、コードの見通しを良くしてくれるので重宝できます。

まだ as を使ったキャストが多いコードがあれば、ぜひ境界部分から少しずつ Zod に置き換えてみてください(^o^)/

※ Zodのバージョンによってスキーマの書き方が異なる場合があります。最新情報は Zod公式ドキュメント をご確認ください👀