TypeScript環境で標準APIである「fetch」を型安全に使いこなすガイドです。
基本的なリクエスト(GET/POST)、実務で必須となる汎用ラッパーの作成、React/Next.jsでの活用、コード自動生成やテスト手法まで解説します。
TypeScriptにおけるFetch APIの基本
モダンなWeb開発において、外部サーバーとの通信は避けて通れないテーマです。
JavaScriptの標準APIであるfetchは強力ですが、TypeScriptの環境下で使用する場合、型の恩恵を最大限に活かすためにはいくつかのアプローチを知っておく必要があります。
本記事では、公式ドキュメントの仕様に基づき、fetchの基本的な使い方から、実務で使えるサンプルを解説します。
- fetchの基本動作と非同期処理
- リクエストの設定
- レスポンスの処理と型安全
fetchの基本動作と非同期処理
APIからデータを取得する際、fetch関数は非同期で実行されます。
fetchの基本的な書き方としては、旧来の.then()メソッドを使用する方法とモダンなasync/awaitを使用する方法があります。
現在は可読性の高さからasync/awaitを用いたfetch requestの実装が主流です。
fetch apiのサンプルとして、簡単なデータ取得関数を作成してみましょう。
fetchメソッドは、成功するとResponseオブジェクトをラップしたPromiseを返します。
// ユーザー情報を取得する基本的なfetch関数
async function fetchUsers() {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
// 【重要】HTTPステータスコードが200番台以外の場合はエラーを投げる
if (!response.ok) {
throw new Error(`HTTPエラー: ${response.status}`);
}
const data = await response.json();
console.log('データ取得成功:', data);
return data;
} catch (error) {
// ネットワークエラーや、上記で投げたHTTPエラーをここでキャッチ
console.error('フェッチに失敗しました:', error);
}
}
fetchUsers();リクエストの設定
実際の開発では、単にURLを叩くだけでなく、GETリクエストにクエリパラメータを付与したり、POSTリクエストでボディを送信したりと様々な設定が必要です。
TypeScriptでこれらのオプションを指定する際、オプションの型として標準のRequestInitインターフェースを活用します。
これにより、ヘッダーやメソッドの指定ミスをコンパイル時に防ぐことができます。
// --- GETリクエスト(クエリパラメータ付き)の例 ---
async function fetchUserById(userId: number) {
// typescript fetch get with parameters
// URLSearchParamsを使うとエンコードも自動で行われ安全です
const params = new URLSearchParams({ id: userId.toString() });
const url = `https://api.example.com/users?${params.toString()}`;
// typescript fetch get example
const response = await fetch(url);
if (!response.ok) throw new Error('GET Request failed');
return response.json();
}
// --- POSTリクエスト(ボディとヘッダー設定)の例 ---
interface CreateUserDto {
name: string;
email: string;
}
async function createUser(userData: CreateUserDto) {
const url = 'https://api.example.com/users';
// RequestInit型を指定することで、入力補完や型チェックが効く
const options: RequestInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json', // JSON送信時には必須
'Authorization': 'Bearer YOUR_TOKEN_HERE'
},
// 必ずJSON文字列に変換する
body: JSON.stringify(userData),
};
const response = await fetch(url, options);
if (!response.ok) throw new Error('POST Request failed');
return response.json();
}レスポンスの処理と型安全
APIからデータを取得するプロセスにおいて、TypeScriptを導入する最大のメリットが「レスポンスへの型定義」です。
fetchが返すレスポンスのオブジェクトから.json()メソッドを呼び出してJSONデータを取り出しますが、実は標準仕様ではこの戻り値の型はPromise<any>になっています。
つまり、そのままでは型安全ではありません。
取得したJSONに対して任意の型定義を紐づけるためには、ジェネリクスを用いたラッパー関数を作成するのが実務におけるベストプラクティスです。
// 取得したいデータの型定義
interface User {
id: number;
name: string;
email: string;
}
// ジェネリクス <T> を使った汎用的なfetchラッパー関数
async function fetchTypedData<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// ここでジェネリクスとして渡された型 T として解釈させる
const data = await response.json();
return data as T;
}
// 実際の使用例
async function main() {
try {
// 呼び出し時に <User[]> と指定することで、戻り値に型がつく
const users = await fetchTypedData<User[]>('https://jsonplaceholder.typicode.com/users');
// usersは User[] 型として認識されるため、補完が効きミスを防げる
users.forEach(user => {
console.log(`ID: ${user.id}, Name: ${user.name}`);
// console.log(user.age); // User型にageはないため、コンパイルエラーになる(安全!)
});
} catch (error) {
console.error(error);
}
}実践的な実装とエラーハンドリング
実際のプロダクト開発では、通信遅延、予期せぬサーバーエラー、環境による制約など様々なトラブルが発生します。
ここでは、実務で頻出するエラーの対処法、プロジェクト全体でコードを再利用しやすくするラッパー関数の設計について解説します。
- トラブルシューティングとエラー制御
- 汎用的なラッパー関数の作成
トラブルシューティングとエラー制御
API通信において、つまずきやすいのが環境やネットワークに起因するエラーです。
ここでは代表的な3つのトラブルと制御方法を見ていきます。
まず、Node.js環境や古いテスト環境で実行した際に「fetch is not defined」というエラーに遭遇することがあります。
これは、ブラウザ標準のAPIであるfetchがその環境に存在しないために起こります。
この場合、後述するライブラリ(node-fetchなど)を導入するか、環境のアップデートが必要です。
次に厄介なのが、「fetch cors」エラーです。
異なるドメインのAPIをブラウザから叩こうとした際に、ブラウザのセキュリティ機構によってブロックされる現象です。
fetchのオプションでmode: 'cors'を指定することはできますが、根本的な解決にはサーバー側で適切なCORSヘッダー(Access-Control-Allow-Originなど)を設定してもらう必要がある点に注意してください。
実務上もっとも実装漏れが多いのがタイムアウトの処理です。
標準のfetchにはタイムアウト機能が組み込まれていないため、ネットワークが不安定な場合、アプリが永遠に待ち状態になってしまう危険性があります。
/**
* タイムアウト機能付きのfetch関数
* @param url リクエストURL
* @param options fetchのオプション
* @param timeoutMs タイムアウト時間(ミリ秒) - デフォルトは8000ms
*/
async function fetchWithTimeout(
url: string,
options: RequestInit = {},
timeoutMs: number = 8000
): Promise<Response> {
// 通信を中断するためのコントローラーを作成
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal, // signalを渡すことで中断可能にする
});
// HTTPエラーのハンドリング
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
return response;
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`リクエストがタイムアウトしました (${timeoutMs}ms)`);
}
throw error;
} finally {
// 成功・失敗に関わらず、タイマーを確実にクリアする
clearTimeout(id);
}
}
// 実行例
// const data = await fetchWithTimeout('https://api.example.com/data', { method: 'GET' }, 5000);汎用的なラッパー関数の作成
プロジェクトの規模が大きくなると、コンポーネントごとに毎回fetchを書いて、ヘッダーを設定して、.json()でパースして……と同じ処理を繰り返すのは非効率です。
そこで、共通の処理をまとめたラッパー関数を作成するのがベストプラクティスです。
このとき、TypeScriptの「ジェネリクス」を活用したgenericな設計にすることで、どんなAPIエンドポイントを叩いても、戻り値に正しい型を付与できるようになります。
大規模な開発になれば、OpenAPIなどのAPI定義書から、型安全なAPIクライアントを生成する手法も一般的ですが、その土台としても基本的なラッパーの仕組みを理解しておくことは必須です。
// 独自のAPIエラークラス(ステータスコードなどを保持)
class ApiError extends Error {
constructor(public status: number, public message: string, public data?: any) {
super(message);
this.name = 'ApiError';
}
}
// 共通設定をまとめたAPIクライアントオブジェクト
const apiClient = {
/**
* ジェネリクス <T> を用いた汎用fetchメソッド
*/
async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const baseUrl = 'https://api.example.com/v1';
// デフォルトヘッダーの設定(認証トークンやContent-Typeなど)
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token') || ''}`,
...options.headers,
};
const response = await fetch(`${baseUrl}${endpoint}`, { ...options, headers });
// 204 No Content(レスポンスボディがない場合)の処理
if (response.status === 204) {
return {} as T;
}
const data = await response.json().catch(() => null);
// エラーハンドリング:ステータスコードとサーバーからの詳細なエラー情報を投げる
if (!response.ok) {
throw new ApiError(
response.status,
data?.message || 'APIリクエストに失敗しました',
data
);
}
// typescript fetch generic: 取得したデータを指定された型 T として返す
return data as T;
},
// HTTPメソッドごとのショートカット(さらに使いやすくする)
get<T>(endpoint: string, options?: RequestInit) {
return this.request<T>(endpoint, { ...options, method: 'GET' });
},
post<T>(endpoint: string, body: any, options?: RequestInit) {
return this.request<T>(endpoint, {
...options,
method: 'POST',
body: JSON.stringify(body),
});
},
};
// --- 使用例 ---
interface UserResponse {
id: number;
name: string;
}
async function fetchMyProfile() {
try {
// 呼び出し側は非常にシンプルになり、戻り値(user)にはUserResponse型がつく
const user = await apiClient.get<UserResponse>('/users/me');
console.log(`こんにちは、${user.name}さん!`);
} catch (error) {
if (error instanceof ApiError) {
if (error.status === 401) {
console.error('セッションが切れました。再ログインしてください。');
} else {
console.error(`サーバーエラー: ${error.message}`);
}
}
}
}フレームワーク・ライブラリ別の活用
共通のラッパー関数などを作成し、プレーンなTypeScriptでのfetchの扱い方に慣れたら、次は実際の開発環境での使われ方を見ていきましょう。
ReactやNext.jsなどのフロントエンド環境から、Node.jsバックエンド環境まで、それぞれの特性やフレームワークの仕様に合わせたベストプラクティスが存在します。
- フロントエンド環境での利用
- Node.js環境での利用とAxiosとの比較
フロントエンド環境での利用
フロントエンド開発において、fetchの使い方はフレームワークごとに進化しており、それぞれの流儀に合わせることが重要です。
React単体でのデータ取得では、useEffectフック内でfetchを呼び出し、取得したデータをステート(useState)に保存するパターンが基本となります。
しかし、最近のモダンなフレームワークでは高度な機能が組み込まれています。
例えば、Next.js環境では、Web標準のfetchがフレームワーク側で拡張されています。
リクエストごとの細かいキャッシュ制御(force-cacheやno-store)や同一データの重複取得の排除が自動で行われるため、パフォーマンスの最適化が容易です。
また、VueベースのフレームワークであるNuxt 3では、標準のfetchの代わりに$fetch(内部的にはofetchというライブラリ)や、コンポーザブルであるuseFetchを利用するのがベストプラクティスです。
これにより、レスポンスの型推論が自動化され、SSR(サーバーサイドレンダリング)時にサーバーとクライアントで二重に通信が発生してしまう問題を防いでくれます。
import { useState, useEffect } from 'react';
// APIから取得するデータの型定義
interface User {
id: number;
name: string;
}
export const UserProfile = ({ userId }: { userId: number }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// コンポーネントのアンマウント時に通信をキャンセルするためのコントローラー
const abortController = new AbortController();
const fetchUser = async () => {
setLoading(true);
try {
// react typescript fetch example
// signalオプションにコントローラーを渡すことでキャンセル可能にする
const response = await fetch(`https://api.example.com/users/${userId}`, {
signal: abortController.signal
});
if (!response.ok) throw new Error('ユーザー情報の取得に失敗しました');
const data: User = await response.json();
setUser(data);
} catch (err: any) {
// 【重要】キャンセルによるエラー(AbortError)の場合は無視する
if (err.name === 'AbortError') {
console.log('Fetch aborted: 別のページへ遷移しました');
return;
}
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUser();
// クリーンアップ関数:コンポーネントが破棄される時に通信をキャンセル
return () => abortController.abort();
}, [userId]); // userIdが変わるたびに再実行される
if (loading) return <div>ローディング中...</div>;
if (error) return <div>エラー: {error}</div>;
return <div>ユーザー名: {user?.name}</div>;
};Node.js環境での利用とAxiosとの比較
バックエンド(Node.js)から外部のWeb APIを叩く際にもデータ取得処理は必要です。
かつてのNode.js環境にはブラウザのような標準のfetchAPIが存在しなかったため、サードパーティ製のライブラリであるnode-fetchを利用して実装するのが一般的でした。
そのため、古いプロジェクトのコードベースでは現在でもimport fetch from 'node-fetch'という記述をよく見かけます。
しかし、Node.js v18以降は標準仕様としてfetch APIが組み込まれました。
そのため、現在では特別なライブラリをインストールすることなく、ブラウザと全く同じコードで実装できるようになっています。
ここで実務上よく議論になるのが、「標準のfetchを使うべきか、それともデファクトスタンダードである人気ライブラリのAxiosを使うべきか」という点です。
Axiosは、JSONの自動パース、通信キャンセルの簡略化、「HTTPステータスコードが400番台や500番台の時に自動的に例外(catch)として扱ってくれる」など、開発者体験に優れています。
対してfetchは、自前で設定しなければならない記述量が増えますが、外部ライブラリへの依存を減らせるというメリットがあります。
interface Todo {
userId: number;
id: number;
title: string;
completed: boolean;
}
// --- 1. 標準 fetch を使った場合(Node.js v18以降、またはブラウザ環境)---
async function getTodoWithFetch() {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/todos/1');
// 【要注意】fetchの場合は自分でHTTPステータスをチェックし、手動でエラーを投げる
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
// JSONへのパースも手動で行う必要がある
const data = await response.json() as Todo;
console.log('Fetch result:', data.title);
} catch (error) {
console.error('Fetch error:', error);
}
}
// --- 2. Axios を使った場合(別途 npm install axios が必要)---
import axios from 'axios';
async function getTodoWithAxios() {
try {
// ジェネリクス <Todo> でレスポンスの型を直接指定できる
const response = await axios.get<Todo>('https://jsonplaceholder.typicode.com/todos/1');
// response.okのチェックは不要(404や500エラーは自動で下の catch へ飛ぶ)
// JSONのパースも自動で行われ、実データは response.data に格納される
console.log('Axios result:', response.data.title);
} catch (error) {
// Axios独自のエラーかどうかを型ガードで判定できる
if (axios.isAxiosError(error)) {
console.error('Axios error status:', error.response?.status);
} else {
console.error('Unexpected error:', error);
}
}
}自動生成とテスト自動化
フロントエンドとバックエンドの連携が複雑化し、プロジェクトの規模が大きくなるにつれて、手動でAPIの型定義や通信処理を保守するのは限界を迎えます。
「バックエンドでAPIの仕様が変わったのに、フロントエンドの型を直し忘れて本番でエラーになった」という事故を防ぐためにも、ここからは自動生成(コードジェネレート)とテスト自動化の手法について解説します。
- OpenAPIを活用したコード生成
- JestやVitestでのfetchのモック・テスト
OpenAPIを活用したコード生成
APIの仕様書として広く普及しているOpenAPIを利用しているプロジェクトであれば、フロントエンドの通信コードを手書きする必要はありません。
OpenAPIの定義ファイル(yamlやjson)から、型定義とAPIクライアントを自動生成するのが現在のベストプラクティスです。
TypeScriptでfetchを使用する場合、openapi generatorと呼ばれるツール群を活用します。
これにより、型安全な実装が完了します。
# typescript-fetch ジェネレーターを指定してAPIクライアントを出力
npx @openapitools/openapi-generator-cli generate -i ./openapi.yaml -g typescript-fetch -o ./generated-api// 自動生成されたディレクトリから必要なクラスをインポート
import { Configuration, UsersApi } from './generated-api';
// 1. APIクライアントの設定(Configuration)を定義
// ここで認証トークンなどを一元管理することで、自動生成ファイルを汚さずに済む
const apiConfig = new Configuration({
basePath: 'https://api.example.com/v1',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token') || ''}`,
},
// fetchApiインターフェースを上書きして独自のラッパーを噛ませることも可能
});
// 2. 設定を渡してAPIクライアントのインスタンスを作成
const usersApi = new UsersApi(apiConfig);
// 3. 実際のデータ取得処理
async function fetchUserList() {
try {
// パラメータの型チェックから戻り値の型付けまで、すべて自動で補完・検証される
const users = await usersApi.getUsers({ limit: 10, offset: 0 });
console.log('取得したユーザー一覧:', users);
return users;
} catch (error) {
console.error('ユーザー一覧の取得に失敗:', error);
}
}JestやVitestでのfetchのモック・テスト
実務において、作成した非同期の取得関数やAPIクライアントをテストする際、実際のAPIサーバーへ通信を発生させてはいけません。
テストがネットワークの状況に依存して不安定になったり、サーバーに不要な負荷をかけたりするからです。
そこで、テストランナー側でfetch関数を偽物にすり替える手法をとります。
従来はJestを用いた構成が標準でしたが、近年では高速でモダンなVitestを利用した構成も人気を集めています。
// テスト対象の関数(例として、先ほど作成したfetch関数を想定)
import { fetchUsers } from './api';
describe('fetchUsers API通信のテスト', () => {
// テストケースごとにfetchのモックをリセットする(超重要!)
afterEach(() => {
jest.restoreAllMocks();
// Vitestの場合は vi.restoreAllMocks();
});
it('【正常系】APIから正しくデータを取得し、JSONを返すこと', async () => {
// 1. fetchをモック化し、ダミーの成功レスポンスを定義(jest mock fetch typescript)
const mockResponse = [{ id: 1, name: 'Taro' }, { id: 2, name: 'Jiro' }];
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
status: 200,
json: async () => mockResponse,
} as Response);
// Vitestの場合は vi.spyOn(global, 'fetch').mockResolvedValue(...)
// 2. 関数を実行
const data = await fetchUsers();
// 3. アサーション(検証)
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith('https://jsonplaceholder.typicode.com/users');
expect(data).toEqual(mockResponse);
});
it('【異常系】HTTPステータスが200番台以外の場合、エラーを投げること', async () => {
// 1. fetchをモック化し、ダミーのエラーレスポンスを定義
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
} as Response);
// 2 & 3. エラーが正しくスローされるかを検証
await expect(fetchUsers()).rejects.toThrow('HTTPエラー: 404');
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
