TypeScript + React 実践 #6 fetch と API レスポンスの型付け

読了 7分

#5 Context とジェネリックコンポーネント までで、コンポーネント内側のほぼすべての型付けの判断を扱いました。最終回は最も危険で、それゆえ最もよく失敗する部分 — 外部から来たデータの型付けです。

fetch().then(r => r.json()) の戻り値は unknown です。これを User だと私たちが言い張ったところで、本当に User になるわけではありません。今回はその危険性を正確に押さえ、安全に絞り込む方法を整理します。

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 はコンパイル時のツールなので、ランタイムに実際に来るデータを検証できません。 ここには別のツールが必要です。

ユーザー定義型ガードで絞り込む #

最も依存関係なしに絞り込む方法は型ガード関数です。基礎講座 #4 union, literal, narrowing で扱ったパターンですね。

型ガード — ライブラリなしで
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スキーマ + 推論
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) が核心です。入って来たデータがスキーマと1か所でも合わなければエラーを投げ、通れば、その時点から型が User に絞り込まれます。コンパイル時の型とランタイム検証が同じ1か所で定義されます。

変換とデフォルト値 #

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 }

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

ジェネリック 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 を呼んで state に入れることです。4編のフォームのように、リクエスト/成功/失敗の3つの状態を1つのオブジェクトにまとめるとすっきりします。

リクエスト状態を1つのunionに
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 として使う union が核心です。JSX の分岐の中で state.datasuccess の枝でだけアクセス可能なので、「データがない状態で data を読む」のような事故がコンパイル段階で捕まります。

cancelled フラグは モダンReact #4 で扱った cleanup パターンです。コンポーネントがアンマウントされた後に setState が呼ばれてメモリリーク/警告が出るのを防ぎます。

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

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

  • TanStack Query — クライアントサイドのデータフェッチの標準。fetcher 関数だけ与えればキャッシング/リトライ/ローディング状態を自動で管理。TypeScript と非常に相性が良いです。
  • Server Component + Server Actions (Next.js) — モダンReactシリーズで扱ったパターン。サーバーでフェッチが終わるためクライアントの fetch コードがほぼ消えます。

どちらでも外部データ → unknown → スキーマで絞り込み → 型安全に使うという流れはそのままです。zod のような検証層はどんな環境でも価値があります。

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

アプリ起動時に読む環境変数、外部設定ファイルもまったく同じ危険を抱えています。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は狭いunion

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

シリーズを終えるにあたって #

6編を経て整理したコアを、改めて一行ずつまとめると:

  1. セットアップ — Vite + react-ts、strict モード、推論を信頼(#1)
  2. props/childrentypeComponentProps、discriminated union、ReactNode (#2)
  3. hooks — 推論を信頼、null 初期値は明示、reducer は union + exhaustiveness(#3)
  4. イベントとフォームReact.XXXEvent<要素>currentTargetFormData 絞り込み(#4)
  5. Context とジェネリクスnull + ヘルパー、state/dispatch 分離、多態コンポーネント(#5)
  6. 外部データfetchunknown、zod スキーマで検証 + 型推論

この流れが頭に入ると、新しいコンポーネントを作るたびに同じ意思決定パターンが繰り返されるのが見えてきます。それに慣れた時点が、TypeScript が煩わしいものではなく頼もしい同僚になる地点です。

次のシリーズではさらに一段深く、TypeScript 自体の応用 — conditional types、mapped types、infer、型ガードパターンを扱う予定です。実戦で出会う難しい型付けの問題を解く道具たちです。

X