目次
21 章

fetch と API レスポンスのタイピング

外部データを安全に扱う流れ — fetch は unknown、zod スキーマで検証と型を同時に。さらに RSC 環境で fetch が持つ意味まで押さえ、4部へとつなげます。

20章まででコンポーネントの内側のほぼすべてのタイピングの決定を扱いました。本章は 3部の最後です。もっとも危険で、それゆえもっともよくミスする部分である 外部から来たデータのタイピング を見ていきます。

fetch().then(r => r.json()) の戻り値は unknown です。これを User だと断定したからといって本当に User になるわけではありません。本章ではその危険を正確に押さえ、安全にナローイングする方法を整理します。さらに最後の節では 4部(モダン Next.js)の Server Components 環境で fetch が持つ新しい意味 を押さえ、3部から 4部へ自然につながるようにします。

fetch + json() はなぜ unknown なのか #

Response.json() の型シグネチャを見ると次の通りです。

lib.dom.d.ts(抜粋)
interface Body {
  json(): Promise<any>;   // 実は unknown として扱うのが安全
}

any として定義されているので、そのまま受け取るとどんな形でも通ってしまいます。よい習慣は 最初の段階で unknown として受け取ること です。

外部データは unknown で
const res = await fetch('/api/me');
const data: unknown = await res.json();
// data をそのまま使うとほぼすべての作業が塞がれる — ナローイングが必要

unknown で受け取ると、次の使用箇所で自然に「どうナローイングするのか」という質問が出てきます。TypeScript が意図的に検証を強制する部分です。

ジェネリック fetcher — よくあるパターン、そして危険 #

Web で検索するともっともよく出てくるパターンは次です。

よくあるジェネリック fetcher
async function api<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (await res.json()) as T;
}

// 使用箇所
type User = { id: string; name: string };
const me = await api<User>('/api/me');
// me は User として推論される

コードはきれいです。しかしこれが実際に安全という意味ではありません。 as T は単純にコンパイラを騙すだけで、サーバーが本当に User の形を送ったという保証はありません。次のようなことが頻繁に起こります。

  • サーバーが name を抜いて nickname だけ送る → クライアントが me.name を読んで undefined
  • id が数値に変わる → 文字列メソッド呼び出しがランタイムエラー
  • バックエンドはまともなのにネットワークが別のレスポンス(プロキシエラーページの HTML)を差し挟む

TypeScript はコンパイル時の道具なので、ランタイムに実際に来るデータを検証できません。 この部分には別途の道具が必要です。

ユーザー定義の型ガードでナローイング #

もっとも依存性なしにナローイングする方法は 型ガード関数 です。

型ガード — ライブラリなし
type User = { id: string; name: string };

function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) return false;
  const v = value as Record<string, unknown>;
  return typeof v.id === 'string' && typeof v.name === 'string';
}

async function fetchMe(): Promise<User> {
  const res = await fetch('/api/me');
  const data: unknown = await res.json();
  if (!isUser(data)) throw new Error('不正なレスポンス');
  return data;
}

長所: 依存性がありません。短所: フィールドが増えるほどガード関数が長くなり、サーバー仕様が変わるとあちこちを手動で直す必要があります。コアなエンティティが 2 〜 3 個だけなら手で書く価値があります。

zod — 一度定義して型 + ランタイム検証を同時に #

フィールドが多い、レスポンスの種類が多様になると zod のようなスキーマライブラリがほぼ必須です。zod は一度書いたスキーマから型を推論してくれ、ランタイム検証も同じコードで行ってくれます。

zod インストール
pnpm add zod
zod スキーマ + 推論
import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
});

type User = z.infer<typeof UserSchema>;
// User = { id: string; name: string; email: string; createdAt: string }

async function fetchMe(): Promise<User> {
  const res = await fetch('/api/me');
  const data: unknown = await res.json();
  return UserSchema.parse(data);   // スキーマと合わなければ throw
}

UserSchema.parse(data) が肝です。来たデータがスキーマと一箇所でも合わなければエラーを投げ、通れば、その時点から型が User にナローイングされます。コンパイル時の型とランタイムの検証が同じ一箇所で定義 されます。

変換と既定値 #

zod は単純な検証を超えて変換もサポートします。サーバーから ISO 文字列で来る日付を Date オブジェクトに変えて受け取りたい場合は次の通りです。

zod で日付変換
const PostSchema = z.object({
  id: z.string(),
  title: z.string(),
  createdAt: z.string().transform((s) => new Date(s)),
  views: z.number().default(0),
});

type Post = z.infer<typeof PostSchema>;
// { id: string; title: string; createdAt: Date; views: number }

スキーマ一箇所だけ手を入れれば、クライアントコードでは常に Date オブジェクトとして受け取れます。変換ロジックが一箇所に集まるので保守が楽です。

zod を使うときの選択肢 #

道具依存性検証型推論適する場合
as T(キャスト)なしコンパイラを騙す一時・学習用
型ガード(value is T)なし✓(自分で書く)エンティティ 2 〜 3 個
zodzod 1 パッケージ✓(z.infer)エンティティ多数、変換・既定値が必要

本書は zod を既定の選択肢にします。27章(Server Actions とフォーム)でも zod に再び出会いますし、付録 A(旧 React マイグレーション)でも旧 PropTypes・手検証コードから zod への移行を扱います。

ジェネリック fetcher + zod = 安全な組み合わせ #

api<T> のきれいさと zod の安全性を合わせると次の形になります。

検証を強制する fetcher
import { z } from 'zod';

async function apiGet<T>(url: string, schema: z.ZodSchema<T>): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data: unknown = await res.json();
  return schema.parse(data);
}

// 使用箇所
const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
});

const me = await apiGet('/api/me', UserSchema);
// me は z.infer<typeof UserSchema> として推論される

呼び出し側で スキーマを明示することを強制 します。as T でコンパイラを騙す部分をまるごと無くしたのです。手間が一度増えますが、その時点で外部データのリスクを遮断しておきます。

コンポーネントで使う — useEffect パターン #

もっとも単純なパターンは useEffect の中で fetch を呼んで状態に入れることです。17章の discriminated union モデルと組み合わせると、リクエスト・成功・失敗の 3 つの状態 を一つのオブジェクトに束ねるのがきれいです。

リクエスト状態を一つのユニオンに
type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function MePage() {
  const [state, setState] = useState<RequestState<User>>({ status: 'idle' });

  useEffect(() => {
    let cancelled = false;
    setState({ status: 'loading' });

    apiGet('/api/me', UserSchema)
      .then((data) => {
        if (!cancelled) setState({ status: 'success', data });
      })
      .catch((err: unknown) => {
        if (!cancelled) {
          setState({
            status: 'error',
            error: err instanceof Error ? err.message : '不明なエラー',
          });
        }
      });

    return () => {
      cancelled = true;
    };
  }, []);

  if (state.status === 'idle' || state.status === 'loading') return <p>読み込み中...</p>;
  if (state.status === 'error') return <p>エラー: {state.error}</p>;

  return <p>{state.data.name}</p>;     // ここで data は User にナローイングされる
}

status を discriminator として使うユニオンが肝です。JSX 分岐の中で state.datasuccess の枝でだけアクセス可能なので、「データがない状態で data を読む」のような事故がコンパイル段階で検出されます。

cancelled フラグは 10章(useEffect)で扱った cleanup パターンです。コンポーネントがアンマウントされた後 setState が呼び出されてメモリリークや警告が出るのを防いでくれます。

より大きな絵 — データフェッチライブラリと RSC #

上のパターンは学習用には良いですが、実際のアプリで直接 useEffect でフェッチを書くことは次第に減っています。キャッシュ、リトライ、ロード状態の同期のようなものが毎回繰り返されるからです。通常は次の 2 つのうちのどちらかに進みます。

1) TanStack Query — クライアントサイドのデータフェッチ #

fetcher 関数を渡すだけでキャッシュ・リトライ・ロード状態を自動で管理します。TypeScript とも非常によく組み合わさります。SPA やルーティングの中でクライアントが直接データをフェッチする必要がある場合(ダッシュボードなど)に適しています。

本書の 5 〜 6部では直接導入しませんが、上の useEffect + fetch パターンは実は TanStack Query が内部的に行っていることの単純化版です。手に馴染ませておくとライブラリの学習が早くなります。

2) Server Components + Server Actions(Next.js) — 4部のモデル #

本書の 4部で本格的に扱うモデルです。サーバーでフェッチが終わるので、クライアントの fetch コードがほぼ消えます。

4部で出会うモデルのプレビュー(RSC)
// app/users/[userId]/page.tsx — Server Component
async function UserPage({ params }: { params: { userId: string } }) {
  const res = await fetch(`https://api.example.com/users/${params.userId}`);
  const data: unknown = await res.json();
  const user = UserSchema.parse(data);   // サーバーで検証

  return <p>{user.name}</p>;
}

3 つの違いが一目で見えます。

  • useState / useEffect がありません。 関数コンポーネントがそのまま async です。
  • ロード状態を手で扱いません。 サーバーで await が終わってからレンダリングが始まります(Suspense がその間を埋めます — 26章)。
  • 検証は同じ zod スキーマで サーバーで行います。本章の UserSchema がそのまま使われます。

zod という 外部データ検証層 はクライアント領域とサーバー領域の両方で有効です。本章で整理した流れ — 外部データ → unknown → スキーマでナローイング → 型安全に使用 — はどの環境でもそのまま通用します。

詳細な RSC モデルとデータフェッチ・キャッシュ・Suspense の組み合わせは 25章(データフェッチとキャッシュ)、26章(Suspense と use())で扱います。

環境変数と外部設定も同じ原則 #

アプリ起動時点で読む環境変数、外部設定ファイルも正確に同じリスクを抱えています。process.env.API_URL の型は string | undefined ですが、いざコードでは常に string であるかのように扱い、デプロイ後に事故になるパターンをよく見かけます。

zod はここでも光ります。

env も検証
const EnvSchema = z.object({
  API_URL: z.string().url(),
  NODE_ENV: z.enum(['development', 'production', 'test']),
});

export const env = EnvSchema.parse(process.env);
// env.API_URL は string、env.NODE_ENV は狭いユニオン

アプリ起動時点で一度検証し、その後は TypeScript が保証する形で使います。誤った環境ではアプリが起動自体できないようにするのが肝です。

33章(デプロイと観測性)で実際の運用環境変数の管理手順を扱いますが、上のパターンがその中に自然に組み込まれます。

自分でやってみる #

GitHub API からユーザー情報を取ってくる小さな例題で本章の流れを手に馴染ませてみましょう。

src/api.ts:

src/api.ts
import { z } from 'zod';

export const GitHubUserSchema = z.object({
  login: z.string(),
  id: z.number(),
  name: z.string().nullable(),
  bio: z.string().nullable(),
  public_repos: z.number(),
});

export type GitHubUser = z.infer<typeof GitHubUserSchema>;

export async function fetchGitHubUser(username: string): Promise<GitHubUser> {
  const res = await fetch(`https://api.github.com/users/${username}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data: unknown = await res.json();
  return GitHubUserSchema.parse(data);
}

src/UserCard.tsx:

src/UserCard.tsx
import { useEffect, useState } from 'react';
import { fetchGitHubUser, type GitHubUser } from './api';

type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function UserCard({ username }: { username: string }) {
  const [state, setState] = useState<RequestState<GitHubUser>>({ status: 'idle' });

  useEffect(() => {
    let cancelled = false;
    setState({ status: 'loading' });

    fetchGitHubUser(username)
      .then((data) => {
        if (!cancelled) setState({ status: 'success', data });
      })
      .catch((err: unknown) => {
        if (!cancelled) {
          setState({
            status: 'error',
            error: err instanceof Error ? err.message : '不明なエラー',
          });
        }
      });

    return () => {
      cancelled = true;
    };
  }, [username]);

  if (state.status === 'idle' || state.status === 'loading') return <p>読み込み中...</p>;
  if (state.status === 'error') return <p>エラー: {state.error}</p>;

  const { data } = state;
  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>{data.login}{data.name ? ` (${data.name})` : ''}</h2>
      <p>リポジトリ数: {data.public_repos}</p>
      {data.bio && <p>{data.bio}</p>}
    </div>
  );
}

export default UserCard;

src/App.tsx:

src/App.tsx
import UserCard from './UserCard';

function App() {
  return (
    <>
      <UserCard username="curtis" />
      <UserCard username="存在しないユーザーxyzabc" />
    </>
  );
}

export default App;

保存して動作を確認してください。最初のカードは正常データ、二つ目はエラーメッセージが表示されます。GitHubUserSchema で一つのフィールドを誤って定義(例: id: z.string())してみて、来たデータがスキーマと合わないときどう検出されるかも観察してみてください。

練習問題 #

  1. GitHubUserSchematransform を追加して bio: z.string().nullable().transform((b) => b ?? '紹介がありません') にし、コンポーネントの中では常に string として扱えるよう変えてみてください。zod の transform がクライアントコードをどう単純にするかを実際に手に馴染ませます。
  2. apiGet<T>(url, schema) のようなジェネリック fetcher を作り、2 つの異なるエンティティ(GitHubUser、GitHubRepo)を同じ fetcher で呼び出してみてください。呼び出し側でスキーマを明示することが強制されるのを実際に感じてみます。
  3. 環境変数検証パターン。import.meta.env.VITE_API_URLEnvSchema = z.object({ VITE_API_URL: z.string().url() }) で検証する小さなモジュールを作ってみてください。.env に誤った値(URL でない)を入れておくと、アプリ起動時点でエラーになるのを確認します。

一行まとめ: 外部データは unknown で受け取る。as T キャストはコンパイラを騙すだけで安全ではない。zod スキーマで検証 + 型推論(z.infer)を一箇所で定義すれば、コンパイル時とランタイムが一緒に安全になる。リクエスト状態は idle | loading | success | error の discriminated union に束ねる。4部の RSC 環境では useEffect + fetch が消えてサーバーで直接フェッチするが、zod 検証層はそのまま生き残る。

次の章 #

本章で 3部 TypeScript と一緒に が締めくくられます。1 〜 2部の React の主要なビルディングブロックの上に TypeScript の安全網を 6 章にわたってかぶせてきました。props・フック・イベント・フォーム・Context・外部データまで — 新しいコンポーネントを作るときに出会うほぼすべてのタイピングの決定が手に馴染みました。

次の 22章 なぜ Next.js と Server Components なのかから 4部が始まります。本章の最後の節で先取りした RSC モデル — サーバーで直接フェッチし、クライアントの useEffect + fetch がほぼ消えるモデル — の背景と転換点を一箇所にまとめて整理します。本書の回転点となる部です。

X