JavaScript

TypeScriptでfetchのレスポンスを型付けする|unknown・型ガード・Zodの実行例

as Userで型を付けても、APIから届くJSONは検証されません。unknownで受ける型ガードとZodの実装を比較し、整形済みdataの返却、HTTP・JSON・検証エラーの分岐まで動かして確認します。

この記事の目次
  1. as Userは「検査済み」にするコードではない
  2. unknownを受け取り、使う項目を型ガードで確かめる
  3. 検証とデータの整形を分けて考える
  4. Zodで検証し、成功したdataだけを返す
  5. HTTPエラーと検証エラーを空データにまとめない
  6. 小さな検証環境で、不正なJSONを通してみる
  7. 手書きで足りるか、Zodへ移すか

APIから返るユーザー情報に as User を付けた。エディターの赤線は消えたのに、画面では name.trim is not a function が出る。これは、TypeScriptへ伝えた型と、実際に届いた値が食い違っている状態です。

fetchの外から来るJSONは unknown で受け、プロパティを調べてから使います。 小さいデータなら手書きの型ガード、項目が増えるならZodのスキーマで検証すると、どの値を受け入れるのかを管理しやすくなります。

as Userは「検査済み」にするコードではない

as User は、コンパイラーにその値をUserとして扱わせる型アサーションです。実行時に名前を文字列へ変換したり、足りない項目を調べたりはしません。型アサーション自体はコンパイル後に消えます。TypeScript公式の型アサーション解説でも、この点が明示されています。

async function getUser(): Promise<User> と戻り値を書くだけでも、APIのJSONは検査されません。受信側で any の値を返していれば、その宣言をすり抜けます。fetchJson<User>() のようなジェネリック関数も、内部が単なる as T なら事情は同じです。

ここでは「idは正の安全な整数、nameは前後の空白を除いて1文字以上」という受信ルールを使います。まず、同じデータを型アサーションと型ガードで扱ってみます。

スポンサーリンク

unknownを受け取り、使う項目を型ガードで確かめる

src/manual.ts

export type User = { id: number; name: string };

// 比較用。受信したJSONにこの関数を使わない。
export function unsafeName(raw: unknown): string {
  const user = raw as User;
  return user.name.trim();
}

export function isUser(value: unknown): value is User {
  return typeof value === 'object' && value !== null &&
    !Array.isArray(value) &&
    'id' in value && typeof value.id === 'number' &&
    Number.isSafeInteger(value.id) && value.id > 0 &&
    'name' in value && typeof value.name === 'string' &&
    value.name.trim().length > 0;
}

export function manualName(raw: unknown): string | null {
  if (!isUser(raw)) return null;
  return raw.name.trim();
}

unsafeName({ id: 1, name: 42 }) はコンパイルできても、実行時に落ちます。一方、manualName は検証に通らない値なら null を返し、文字列への操作に進みません。

unknown は値を検証する機能そのものではありません。「中身を確かめるまで、そのプロパティを自由には使わない」という入口です。そこから typeof、null判定、プロパティの存在確認を順に行っています。

value is User は、関数がtrueを返した場合にUserとして扱えることを伝える型述語です。書いた判定が正しいかは実装者の責任なので、型だけ書いて常にtrueを返すようなガードでは守れません。上の例も、項目を増やしたら判定とテストを一緒に変えます。仕組みはTypeScript公式の型述語で確認できます。

検証とデータの整形を分けて考える

この isUser は入力を変更しません。{ id: 1, name: " Aki ", extra: "メモ" } は検証に通りますが、nameの空白もextraも残ります。manualName が表示名を返すときにだけ、空白を取り除いています。

型ガードを通せば余分な項目が消える、という意味ではありません。検証後のオブジェクトを丸ごと別のAPIへ送るのではなく、送信する項目はその用途に合わせて選びます。

Zodで検証し、成功したdataだけを返す

Zodを使う場合は、User型を別に手書きする代わりに z.infer でスキーマから取り出せます。safeParse の成功・失敗を分岐し、成功時には入力のrawではなく parsed.data を使うのがポイントです。Zod公式の基本操作もこの形で説明しています。

src/client.ts

import { z } from 'zod';

export const UserSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().trim().min(1),
});
export type User = z.infer<typeof UserSchema>;

export type UserResult =
  | { ok: true; data: User }
  | { ok: false; kind: 'network' | 'body' }
  | { ok: false; kind: 'http'; status: number }
  | { ok: false; kind: 'schema'; fields: string[] };

export async function loadUser(
  url: string,
  request: typeof fetch = fetch,
): Promise<UserResult> {
  let response: Response;
  try {
    response = await request(url);
  } catch {
    return { ok: false, kind: 'network' };
  }
  if (!response.ok) {
    return { ok: false, kind: 'http', status: response.status };
  }

  let raw: unknown;
  try {
    raw = await response.json();
  } catch {
    return { ok: false, kind: 'body' };
  }
  const parsed = UserSchema.safeParse(raw);
  if (!parsed.success) {
    // 入力値、レスポンス全文、URLやトークンをログへ渡さない。
    const fields = [...new Set(parsed.error.issues.map(issue => {
      const key = issue.path[0];
      return key === 'id' || key === 'name' ? key : 'root';
    }))];
    return { ok: false, kind: 'schema', fields };
  }
  return { ok: true, data: parsed.data };
}

export function userLabel(result: UserResult): string {
  if (result.ok) return result.data.name;
  if (result.kind === 'http' && result.status === 404) {
    return 'ユーザーが見つかりません';
  }
  return 'ユーザー情報を取得できませんでした';
}

このUserSchemaはnameをtrimしてから空文字かを調べます。また、通常の z.object は、定義していないキーを検証後の出力から取り除きます。先ほどの入力なら、返るdataは { id: 1, name: "Aki" } です。未知のキー自体をエラーにしたい場合の z.strictObject とは動きが違います。Zod公式のオブジェクト仕様を参照してください。

スキーマが正しい形だと判断しても、そのユーザー情報を閲覧してよいかは別の問題です。認証・認可はサーバー側で判断します。また、nameが文字列であることはHTMLとして安全であることを意味しません。画面へ出すときはテキストとして扱い、innerHTML にそのまま渡さないでください。

HTTPエラーと検証エラーを空データにまとめない

loadUser は次の4種類に分けて失敗を返します。404でもPromiseが通常どおり解決するfetchの挙動は、fetchでJSONを取得する基本とエラー処理で追えます。

kind 分かること 確認する場所
network fetchの呼び出しが失敗した 接続先、接続状況、ブラウザの通信エラー
http 成功扱いでないHTTPステータスが返った statusとAPI側の仕様
body 本文をJSONとして読み取れなかった 空の本文、不正なJSON、読取中の中断など
schema JSONにはなったがUserの条件に合わない fieldsで示すid・name・rootとAPIの変更

この例のAPIは成功時にUserのJSONを返す契約なので、本文がない204もbodyになります。204を正常な「データなし」として使うAPIなら、その仕様に合わせた成功分岐を追加します。

userLabel は404とその他の失敗を別の文にします。失敗を空のUserや空配列に置き換えると、「取得できなかった」と「取得した結果が0件だった」の区別がつかなくなるためです。ログへ残す場合も、上の結果のkind、status、許可したfields程度から始め、レスポンス全文や認証情報を出さないようにします。

小さな検証環境で、不正なJSONを通してみる

次の2ファイルと、上の src/manual.ts・src/client.ts を同じプロジェクトに保存します。この例はNode.js 24.13.0、TypeScript 7.0.2、Zod 4.6.2で確認しています。

package.json

{
  "name": "fetch-response-validation-example",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "tsc",
    "test": "npm run build && node --test test.mjs"
  },
  "dependencies": { "zod": "4.6.2" },
  "devDependencies": { "typescript": "7.0.2" }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "lib": ["ES2022", "DOM"],
    "strict": true,
    "noEmitOnError": true,
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src/**/*.ts"]
}

npm install のあと npm run build でコンパイルできます。ブラウザ用のコードとして組み込むときは、既存のViteなどのビルド環境にsrcの実装を移し、tsconfigを丸ごと上書きしないでください。

手元で比較するなら、コンパイル後にNodeの --input-type=module を使って次を実行できます。外部APIには接続せず、作ったResponseを返すので、壊れた値も再現できます。

node --input-type=module <<'JS'
import { loadUser, userLabel } from './dist/client.js';
const result = await loadUser('/api/users/1', async () =>
  Response.json({ id: 1, name: 42 }));
console.log(result);
console.log(userLabel(result));
JS

{ ok: false, kind: 'schema', fields: ['name'] } と、取得できなかったことを伝える日本語が出れば、型の異常を画面表示の前で止められています。nameを ' Aki ' に変えると、成功したdataと Aki が返ります。

例の検証では、null、配列、必須項目の欠落、文字列のid、0・小数・安全な整数を超えるid、数値・空文字・空白だけのnameを拒否できることを確認しました。正常値の整形、余分なキーの除去、通信失敗、404、不正なJSON、204、ログ用結果に受信値を残さないことも確認しています。実APIへつなぐ際は、実際の成功・失敗レスポンスでも同じ境界を確かめてください。

手書きで足りるか、Zodへ移すか

今の状態 選び方
1か所で使う、平坦で小さいデータ 手書きガードでも管理しやすい。型と判定の変更漏れをテストで確認する
配列や入れ子、任意項目が増えている Zodで条件をまとめると、検証処理を追いやすい
型と検証ルールを二重に直している スキーマを基準にz.inferで型を取り出す
とりあえずasを外したい 1つのAPI境界だけunknownへ変え、成功時の返り値を決めるところから始める

まず1か所の response.json() をunknownで受け、画面が使う項目を検証してください。次に、正常な値と壊れた値を1つずつ試します。検証済みの値だけを返す入口ができれば、画面のあちこちで as User を重ねずに済みます。

スポンサーリンク