ReactでZodを使い、APIの応答を実行時に確かめる方法

​​React​fetch,TypeScript,Zod

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" が入っていた場合を試しました。

ts
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);
text
$ npx tsx src/without-zod.ts
来年の年齢: 31 string

型の上では number なので、型チェックは通ります。実際には文字列の連結が起きて、4 ではなく "31" になりました。エラーにはならないので、画面に変な値が出るまで気づけません。

Zod で応答のスキーマを書く

Zod では、届くはずのデータの形をスキーマとして書きます。Zod の基本の説明のとおり、z.infer を使うと、スキーマから TypeScript の型を取り出せます。次のコードは schema.ts です。

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 を読む行が、どちらもエラーになりました。

text
$ 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 です。

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 が文字列のときは、次のようになりました。

text
# 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 が文字列のときの中身は次のとおりでした。

text
[
  {
    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件分のデータです。

ts
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 で見分けられる型で持ちます。

ts
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 かどうかで、表示する文を分けます。

tsx
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" にした応答を返しました。

上は猫2匹の名前と年齢と日付の一覧、下は「サーバーから届いたデータの形が想定と違うため、表示できません。」という文が出た画面
正しい応答(上)と、2件目のageが文字列の応答(下)を表示した画面

正しい応答では、一覧に「たま(3歳)2024/5/1から」「みけ(5歳)2023/11/20から」と出ました。age が文字列の応答では、一覧は出ず、エラーの文が出ました。コンソールには、どこが違ったかが出ました。

text
[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() で置きかえる

​Posted by 86