本文へ移動
aapp-hacks
← 記事一覧へ
著者
app-hacks
公開日

TypeScript / Fetch API / API

TypeScriptで書く外部APIフェッチ用の型安全な共通ラッパー関数の実装

HTTPエラーと不正なJSONを区別し、実行時検証まで行うfetchラッパーをTypeScriptで設計する。

fetch()へジェネリクスを付けただけでは、外部APIのレスポンスがその型であることは保証されない。TypeScriptの型はコンパイル後に消えるため、サーバーが仕様と異なるJSONを返しても実行時には検知できないからである。共通ラッパーは、通信失敗、HTTPエラー、JSON解析失敗、データ形式の不一致を別々の境界として扱う必要がある。

エラーを構造化する

呼び出し側が文字列比較で分岐しないよう、HTTPステータスとレスポンス本文を持つ独自エラーを定義する。

export class HttpError extends Error {
  constructor(
    public readonly status: number,
    public readonly statusText: string,
    public readonly body: unknown,
  ) {
    super(`HTTP ${status}: ${statusText}`);
    this.name = 'HttpError';
  }
}

export class ValidationError extends Error {
  constructor(message = 'Response validation failed') {
    super(message);
    this.name = 'ValidationError';
  }
}

fetchは404や500でPromiseをrejectしない。response.okがfalseでもResponseを返すため、HTTPエラーは明示的に判定する。一方、DNS障害、接続拒否、AbortSignalによる中断などではPromiseがrejectされる。両者を同じエラーへ潰すと、再試行やユーザー表示の判断ができなくなる。

実行時検証を受け取るラッパー

ライブラリへ依存しない最小形として、unknownを目的の型へ絞り込むvalidatorを引数で受け取る。

type Validator<T> = (value: unknown) => value is T;

type FetchJsonOptions<T> = RequestInit & {
  validate: Validator<T>;
};

async function readBody(response: Response): Promise<unknown> {
  const contentType = response.headers.get('content-type') ?? '';
  if (contentType.includes('application/json')) {
    return response.json();
  }
  return response.text();
}

export async function fetchJson<T>(
  input: RequestInfo | URL,
  { validate, headers, ...init }: FetchJsonOptions<T>,
): Promise<T> {
  const response = await fetch(input, {
    ...init,
    headers: {
      Accept: 'application/json',
      ...headers,
    },
  });

  const body = await readBody(response);

  if (!response.ok) {
    throw new HttpError(response.status, response.statusText, body);
  }

  if (!validate(body)) {
    throw new ValidationError();
  }

  return body;
}

レスポンス本文は最初からunknownとして扱う。await response.json() as Tと型アサーションすると、検証していない値をTypeScriptへ信用させるだけになる。validate(body)を通過した分岐の内側では、型述語によってbodyTへ絞り込まれる。

readBody()を一度だけ呼ぶ点も重要である。Responseのbodyはストリームであり、response.json()を読んだ後に失敗情報としてresponse.text()を読み直すことはできない。Content-Typeに応じて先に一度だけ読み、その値を成功時と失敗時の両方で利用する。

型ガードを用意する

APIがユーザー配列を返す場合、配列であることだけでなく、要素の必須プロパティまで確認する。

type User = {
  id: number;
  name: string;
  active: boolean;
};

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null;
}

function isUser(value: unknown): value is User {
  return (
    isRecord(value) &&
    typeof value.id === 'number' &&
    typeof value.name === 'string' &&
    typeof value.active === 'boolean'
  );
}

function isUsers(value: unknown): value is User[] {
  return Array.isArray(value) && value.every(isUser);
}

const users = await fetchJson('https://api.example.com/users', {
  validate: isUsers,
  signal: AbortSignal.timeout(8_000),
});

ジェネリクスのTは、ラッパーを呼ぶコードと戻り値を結び付ける。存在しないプロパティを参照すればコンパイル時に分かる。しかし、ネットワークから来た値を検査するのはisUsersである。コンパイル時の型指定と実行時バリデーションは代替関係ではなく、前者が開発中の整合性、後者が外部境界の整合性を担当する。

項目が多い、エラー内容を詳細に表示したい、複数のAPIで同じモデルを共有したい場合はZodなどのスキーマライブラリを使える。その場合も基本構造は同じであり、safeParse()の成功時データだけを返す。単純なas Tへ戻さないことが要点である。

呼び出し側で例外を分類する

例外を握りつぶして空配列を返すと、「データが存在しない」と「取得できなかった」を区別できない。UI層へ分類可能な情報を渡す。

try {
  const users = await fetchJson('/api/users', { validate: isUsers });
  renderUsers(users);
} catch (error: unknown) {
  if (error instanceof HttpError) {
    showHttpMessage(error.status);
  } else if (error instanceof ValidationError) {
    showMaintenanceMessage();
  } else if (error instanceof DOMException && error.name === 'AbortError') {
    showTimeoutMessage();
  } else {
    showNetworkMessage();
  }
}

外部APIのエラー本文には内部情報が含まれる可能性があるため、そのまま画面へ出さない。HttpError.bodyはログや診断の材料として保持し、利用者向けメッセージはステータス分類から生成する。また、GETであっても無条件に再試行するのではなく、回数、待機時間、キャンセル手段を上位層で決める。

共通ラッパーが担うべき責務は、Responseを安全なアプリケーションデータへ変換するところまでである。トースト表示や画面遷移まで含めると利用先ごとの差異を吸収できなくなる。通信、HTTP、解析、検証の境界を保つことで、呼び出し側は失敗の種類に応じた処理を選べる。