ReactでZodを使い、APIの応答を実行時に確かめる方法
API から届いた JSON を Zod のスキーマに通してから使うと、想定と違う形の応答を、画面に出す前に見つけられます。型もスキーマから決まるので、型と実際の中身がずれる心配がありません。
fetch の結果を Zod で確かめる関数を1つ作り、React のコンポーネントから使います。形の違う応答が届いたときは、どの項目がどう違ったかをコンソールに出し、画面にはエラーの文を表示します。
検証環境は Ubuntu 24.04、Node.js 22.22.0、npm 10.9.4 です。使ったのは zod 4.6.7、react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2 です。Zod の動きは、Node.js 上で tsx 4.23.15 を使って確かめました。画面は、esbuild 0.28.2 で変換したコード(開発用のビルド)を Chromium(Google Chrome for Testing 153)で動かし、Playwright 1.56.1 で API の応答を差し替えて確かめました。本物のサーバーにはつないでいません。
型を付けるだけでは、届いた中身は確かめられない
res.json() や JSON.parse の結果に as で型を付けても、中身は何も確かめられません。TypeScript のハンドブックにある型アサーションの説明のとおり、型アサーションはコンパイルのときに消え、実行時のチェックはありません。
検証環境で、age が数値のはずの応答に、文字列の "3" が入っていた場合を試しました。
type CatByHand = { id: number; name: string; age: number };
const body = '[{"id":1,"name":"たま","age":"3"}]';
const cats = JSON.parse(body) as CatByHand[];
const next = cats[0].age + 1;
console.log('来年の年齢:', next, typeof next);
$ npx tsx src/without-zod.ts
来年の年齢: 31 string
型の上では number なので、型チェックは通ります。実際には文字列の連結が起きて、4 ではなく "31" になりました。エラーにはならないので、画面に変な値が出るまで気づけません。
Zod で応答のスキーマを書く
Zod では、届くはずのデータの形をスキーマとして書きます。Zod の基本の説明のとおり、z.infer を使うと、スキーマから TypeScript の型を取り出せます。次のコードは schema.ts です。
import * as z from 'zod';
export const Cat = z.object({
id: z.number().int(),
name: z.string(),
age: z.number(),
status: z.enum(['indoor', 'outdoor']),
adoptedAt: z.iso.datetime().transform((s) => new Date(s)),
note: z.string().nullable(),
});
export const CatList = z.array(Cat);
export type Cat = z.infer<typeof Cat>;
adoptedAt は、JSON では "2024-05-01T09:00:00Z" のような文字列で届きます。z.iso.datetime() で日時の形の文字列かを確かめ、.transform() で Date に変えています。ISO 形式の日時の説明によると、z.iso.datetime() は既定では +09:00 のような時差の付いた形を受け付けません。時差付きで届く API なら { offset: true } を渡します。
.transform() を使うと、Zod の基本の説明にあるとおり、スキーマに入れる値の型と、出てくる値の型が別になります(z.input と z.output)。z.infer で取り出した型では、adoptedAt は Date です。型チェックで確かめたところ、status に決めていない値を入れる行と、null かもしれない note の length を読む行が、どちらもエラーになりました。
$ npx tsc -p .; echo "exit code: $?"
src/types-check.ts(12,3): error TS2322: Type '"sleeping"' is not assignable to type '"indoor" | "outdoor"'.
src/types-check.ts(13,3): error TS2531: Object is possibly 'null'.
exit code: 1
fetch の結果を safeParse に通す関数
応答を確かめる処理は、API ごとに書かず、スキーマを受け取る関数にまとめます。次のコードは fetch-json.ts です。
import * as z from 'zod';
export class ApiResponseError extends Error {
constructor(
public url: string,
public zodError: z.ZodError,
) {
super(`応答の形が想定と違います: ${url}\n${z.prettifyError(zodError)}`);
this.name = 'ApiResponseError';
}
}
export async function fetchJson<T extends z.ZodType>(
url: string,
schema: T,
init?: RequestInit,
): Promise<z.output<T>> {
const res = await fetch(url, init);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
const json: unknown = await res.json();
const result = schema.safeParse(json);
if (!result.success) throw new ApiResponseError(url, result.error);
return result.data;
}
Response.ok は、ステータスが 200〜299 のときだけ true になります。それ以外は、中身を確かめる前にエラーにしています。
res.json() の結果は、いったん unknown で受けます。MDN の json() の説明のとおり、結果は JSON で表せるものなら何でもありうるからです。
safeParse は、基本の説明にあるエラーの扱いのとおり、例外を投げずに success が true か false の結果を返します。true のときの data は、スキーマから決まった型になります。戻り値の型を z.output<T> にしているので、呼ぶ側では fetchJson('/api/cats', CatList) の結果が Cat[] になり、adoptedAt は Date として扱えます。
形の違う応答で、どんなエラーになるか
いろいろな形の応答を CatList.safeParse に通し、z.prettifyError() で読みやすい文字列にして確かめました。たとえば age が文字列のときは、次のようになりました。
# age が文字列
success: false
✖ Invalid input: expected number, received string
→ at [0].age
→ at [0].age は、配列の1件目(0番)の age が違うという意味です。ほかの場合の結果は、次の表のとおりです。
| 届いた応答 | 結果 |
|---|---|
name がない |
expected string, received undefined([0].name) |
status が "away" |
Invalid option: expected one of "indoor"|"outdoor"([0].status) |
adoptedAt が "2024/05/01" |
Invalid ISO datetime([0].adoptedAt) |
2件目の note がない |
expected string, received undefined([1].note) |
配列ではなく { cats: [...] } |
expected array, received object |
知らないキー weight がある |
成功。weight は結果から消える |
2件目の note の結果から、.nullable() は null を受け付けても、キーそのものがない場合は受け付けないことが分かります。キーがないことも許すなら .optional() を使います。
場所と理由を、エラーの文字列ではなくデータとして使いたいときは、error.issues を読みます。age が文字列のときの中身は次のとおりでした。
[
{
expected: 'number',
code: 'invalid_type',
path: [ 0, 'age' ],
message: 'Invalid input: expected number, received string'
}
]
知らないキーと、知らない値の扱い
表の最後の行のとおり、z.object は、スキーマにないキーをエラーにせず、結果から取り除きます。オブジェクトの説明にある既定の動きです。API に項目が増えても、画面は壊れません。
扱いを変えたいときは、z.strictObject か z.looseObject を使います。検証環境で weight の付いた応答を通したところ、z.strictObject では ✖ Unrecognized key: "weight" のエラーになり、z.looseObject では weight: 4.2 が結果に残りました。
一方、status のような決まった値の項目は、API に新しい値が加わると、そのままではエラーになります。一覧の表示を止めたくない項目なら、.catch() で、確かめに失敗したときに代わりに使う値を決めておけます。次のコードの ok は、正しい1件分のデータです。
const TolerantCat = Cat.extend({
status: z.enum(['indoor', 'outdoor', 'unknown']).catch('unknown'),
});
console.log(TolerantCat.parse({ ...ok, status: 'away' }).status);
// => unknown
ただし、.catch() を付けた項目は、違う値が届いてもエラーにならないので、ずれに気づけなくなります。付けるのは、置きかえても困らない項目だけにするのが安全です。
React のコンポーネントから使う
fetchJson を、useEffect で呼ぶフックにします。読み込み中、成功、失敗の3つの状態を、status で見分けられる型で持ちます。
import { useEffect, useState } from 'react';
import { CatList, type Cat } from './schema';
import { fetchJson } from './fetch-json';
type State =
| { status: 'loading' }
| { status: 'success'; cats: Cat[] }
| { status: 'error'; error: Error };
export function useCats(url: string): State {
const [state, setState] = useState<State>({ status: 'loading' });
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading' });
fetchJson(url, CatList, { signal: controller.signal })
.then((cats) => setState({ status: 'success', cats }))
.catch((error: Error) => {
if (controller.signal.aborted) return;
console.error(error);
setState({ status: 'error', error });
});
return () => controller.abort();
}, [url]);
return state;
}
クリーンアップでは abort() を呼び、catch の中で controller.signal.aborted が true なら状態を変えないようにしています。古い要求の結果を画面に使わないためです。React の公式リファレンスにある、エフェクトでデータを取得する例では、同じ目的に ignore という変数を使っています。検証環境では、url が変わって前の要求が中止される場合は試していません。
コンポーネントでは、ApiResponseError かどうかで、表示する文を分けます。
function CatListView() {
const state = useCats('/api/cats');
if (state.status === 'loading') return <p>読み込み中…</p>;
if (state.status === 'error') {
const message =
state.error instanceof ApiResponseError
? 'サーバーから届いたデータの形が想定と違うため、表示できません。'
: '読み込みに失敗しました。';
return <p role="alert">{message}</p>;
}
return (
<ul>
{state.cats.map((cat) => (
<li key={cat.id}>
{cat.name}({cat.age}歳)
{cat.adoptedAt.toLocaleDateString('ja-JP', { timeZone: 'UTC' })}から
</li>
))}
</ul>
);
}
cat.adoptedAt はスキーマで Date に変えてあるので、そのまま toLocaleDateString を呼べます。
ブラウザでの動き
Playwright で /api/cats の応答を差し替え、正しい応答と、2件目の age を文字列の "5" にした応答を返しました。

正しい応答では、一覧に「たま(3歳)2024/5/1から」「みけ(5歳)2023/11/20から」と出ました。age が文字列の応答では、一覧は出ず、エラーの文が出ました。コンソールには、どこが違ったかが出ました。
[console.error] ApiResponseError: 応答の形が想定と違います: /api/cats
✖ Invalid input: expected number, received string
→ at [1].age
ステータス 500 を返した場合は、中身を確かめる前に止まり、「読み込みに失敗しました。」と出ました。
応答は unknown で受けて、スキーマの safeParse を通してから使います。失敗したときは、z.prettifyError() の文字列をコンソールに出し、画面には利用者向けの文を出します。開発者は、コンソールでずれた場所をすぐ見つけられます。
応答を確かめるときのポイント
- 型はスキーマから
z.inferで取り出し、手で同じ型を書かない fetchの結果はunknownで受け、safeParseに通してから使う- 日時の文字列は、スキーマの中で
Dateに変えておくと、コンポーネントでそのまま使える - 知らないキーは
z.objectの既定で取り除かれる。知らない値は既定でエラーになるので、止めたくない項目だけ.catch()で置きかえる





