C
TypeScript/非同期/Lesson 05

Fetch API — レスポンス型を固定するジェネリックヘルパーパターン

30分·theory
このチャプター
5/7
TypeScript

Fetch API — レスポンス型を固定するジェネリックヘルパーパターン

💡 なぜ学ぶべきか?— fetchはanyが漏れ続ける入口

🎯 `fetch().then(r => r.json())` の結果はデフォルトで `Promise` です。型システムが機能しなくなる箇所です。
💼 TypeScript ではジェネリックヘルパー `fetchJson()` を一度作成するだけで、すべての API 呼び出しの結果を安全な型として流すことができます。
ランタイム検証(zod・valibot)と組み合わせることで、外部データが本当にその型であるかどうかまで保証できます。
🔗 エラーレスポンス(`!res.ok`)、ネットワークエラー、パースエラー — この 3 つを分けて扱うのが実務のパターンです。
🏢 실무에서는
Next.js の Server Component でも `await fetch()` が中心になります。同じデータの形をあちこちで使い回すため、一度定義した `User`・`Post` インターフェースが fetch の結果からコンポーネントの props まで一貫して流れる必要があります。fetch の入口で型を固定しなければ、その一貫性が崩れてしまいます。

Response · res.json() · ジェネリックヘルパー

1. Responseの型はわかるが、bodyはany

ts
const res: Response = await fetch('/api/users/1');
// resのメソッド(json·text·blob·formData)はTSが知っているが
// res.json()の戻り値はPromise<any> — bodyの形状は不明
const data = await res.json(); // data: any

2. 最もよくあるパターン — as Promise<User>

ts
const user = await fetch('/api/users/1').then(
  (r) => r.json() as Promise<User>,
);

シンプルですが嘘をつく可能性があります — サーバーが突然異なる形状を返してもコンパイルは通ります。小さなプロジェクトなら問題なし。

3. ジェネリックヘルパー — 再利用可能、キャストを一箇所に集約

ts
async function fetchJson<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
  return res.json() as Promise<T>;
}

// 呼び出し元
const user = await fetchJson<User>('/api/users/1');
const posts = await fetchJson<Post[]>('/api/posts');

4. 本当に安全 — ランタイム検証との組み合わせ

ts
import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
});
type User = z.infer<typeof UserSchema>;

async function fetchUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const raw = await res.json();
  return UserSchema.parse(raw); // 形状が異なればここでthrow
}

ランタイムで実際の形状を検証するため嘘がつけません。外部APIとの通信時に推奨。

💻 🅰️ JS方式 — res.json()の結果はany
// ❌ JS — fetch 結果の形状が不透明

async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('失敗');
  return res.json();
}

async function main() {
  const user = await getUser(42);
  // user のフィールドを IDE が知らない
  console.log(user.name);   // 運が良ければ OK
  console.log(user.naem);   // タイプミス — undefined
  console.log(user.profile.avatar); // ネストされたアクセス — 安全か不明
}
main();
💻 🅱️ TS方式 — ジェネリックヘルパー + レスポンス型の固定
// ✅ TS — ジェネリックヘルパー + 応答タイプ明示

interface User {
  id: number;
  name: string;
  email: string;
  profile: { avatar: string };
}
interface Post {
  id: number;
  title: string;
  authorId: number;
}

class HttpError extends Error {
  constructor(message: string, public status: number) {
    super(message);
    this.name = 'HttpError';
  }
}

// 一度定義 — プロジェクト全体で再利用
async function fetchJson<T>(
  url: string,
  init?: RequestInit,
): Promise<T> {
  const res = await fetch(url, init);
  if (!res.ok) {
    throw new HttpError(`${res.status} ${res.statusText}`, res.status);
  }
  return res.json() as Promise<T>;
}

// 呼び出し元 — 型引数だけ書けば完了
async function main(): Promise<void> {
  try {
    const user = await fetchJson<User>('/api/users/1');
    console.log(user.name);             // ✅
    console.log(user.profile.avatar);   // ✅ ネストまで安全
    // console.log(user.naem);          // ❌ コンパイル拒否

    const posts = await fetchJson<Post[]>('/api/posts');
    posts.forEach((p) => console.log(p.title));

    // POST も同じヘルパーで
    const created = await fetchJson<Post>('/api/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: 'hi', authorId: 1 }),
    });
    console.log('created:', created.id);
  } catch (err) {
    if (err instanceof HttpError) {
      console.log(`HTTP ${err.status}: ${err.message}`);
    } else if (err instanceof Error) {
      console.log('ネットワークエラー:', err.message);
    }
  }
}
main();

💡 💡 fetch + TS 実務パターン5選

1. プロジェクトごとにfetchJsonヘルパーを1つ
fetchの入口を一箇所に集めると、キャッシング・ロギング・エラーポリシーを一元管理できます。

2. as Promiseは「サーバーを信頼する」キャスト
外部APIならzodのようなランタイム検証で形状確認が安全です。

3. HttpErrorのようなカスタムエラーでstatusを保持

ts
class HttpError extends Error {
  constructor(msg: string, public status: number) { super(msg); }
}

statusに応じた異なるUI処理が可能 (401 → ログイン、403 → 権限案内)。

4. RequestInitは標準型 — TSが知っている

ts
fetch(url, {
  method: 'POST',                 // ✅ TSが'POST'|'GET'|...を推論
  headers: { 'Content-Type': 'application/json' }, // ✅
  body: JSON.stringify(payload),
});

5. Next.jsのfetchはキャッシュオプションを受け取る

ts
fetch(url, { cache: 'no-store' });                  // 毎回新規取得
fetch(url, { next: { revalidate: 60 } });           // 60秒ISR

標準fetchのRequestInitをNext.jsが拡張し、型も合わせて拡張されます。

⚡ 実際に試してみよう — fetchJsonヘルパー (モック)

実際のfetchは外部呼び出しになるためモック化しています。ヘルパーの流れだけを体感します。
✏️ JS 코드
📟 コンソール出力
▶ 実行ボタンを押してください
⚠️ ブラウザのサンドボックスで実行 — console.log()のみ対応、alert/fetchは不可

確認クイズ

`async function fetchJson<T>(url: string): Promise<T> { ... return res.json() as Promise<T>; }`の弱点は何ですか?
💡 型アサーション(`as Promise<T>`)はコンパイラに「信じて」と伝えるだけで、ランタイム検証ではありません。サーバーが突然異なる形を返してもコンパイルは通ってしまいます。外部 API と通信する際は、zod・valibot などのライブラリで `res.json()` の結果をもう一度検証するのが安全です。
Fetch API — ジェネリックヘルパー fetchJson<T> - TypeScript