🚴

僕が考えるフロントエンドの APIクライアント設計:型安全で柔軟なfetchの共通化

に公開

Next.jsのAPI呼び出し、もっとスマートにしませんか?

Next.jsでAPI呼び出しを共通化すると、コードがシンプルになり、開発効率が格段に上がります。この記事では、僕がこだわったAPIクライアントの実装を紹介します。特に、次の2点に力を入れました:

•  型安全な戻り値:Result<T>型で成功/失敗を明確にし、TypeScriptの型推論で開発を快適に。
•  関数のみのエクスポート:getやpostを直接エクスポートし、シンプルなインターフェースを提供。

さらに、以下の点も意識しました:

•  fetchの隠蔽:URL構築やエラーハンドリングを内部で完結。
•  クライアント/サーバー両対応:Next.jsのクライアントコンポーネントでもサーバーコンポーネントでも使える。
•  キャメルケース変換:バックエンドのsnake_caseをフロントエンドのcamelCaseに統一。

このAPIクライアントを使えば、API呼び出しがスッキリし、型安全でメンテナンスしやすいコードになります。早速、コードを見ていきましょう!

ソースコード
type CamelCase<T> =
  T extends Record<string, unknown>
    ? { [K in keyof T]: CamelCase<T[K]> }
    : T extends (infer U)[]
      ? U extends Record<string, unknown>
        ? CamelCase<U>[]
        : T
      : T;

const toCamelCase = <T extends Record<string, unknown>>(obj: T): CamelCase<T> => {
  // MEMO: 配列ならば、その要素を再帰的にキャメルケースに変換する
  if (Array.isArray(obj)) {
    return obj.map(toCamelCase) as unknown as CamelCase<T>;
    // MEMO: オブジェクトならば、そのプロパティのキーを再帰的にキャメルケースに変換する
  } else if (obj !== null && typeof obj === 'object') {
    return Object.fromEntries(
      Object.entries(obj).map(([key, value]) => [
        key.replace(/([-_][a-z])/g, (group) =>
          group.toUpperCase().replace('-', '').replace('_', ''),
        ),
        toCamelCase(value as Record<string, unknown>),
      ]),
    ) as unknown as CamelCase<T>;
  }
  return obj as CamelCase<T>;
};

type Result<T, Error> =
  | { ok: true; value: T; response: Response }
  | { ok: false; error: Error; response: Response };

const getUrl = (endpoint: string, params?: Record<string, unknown>): URL => {
  const url = new URL("example.com" + endpoint);
  if (params) {
    Object.keys(params).forEach((key) => url.searchParams.append(key, String(params[key])));
  }
  return url;
};

const getCookies = async (): Promise<string> => {
  if (typeof window !== 'undefined') {
    return Promise.resolve(document.cookie);
  } else {
    try {
      const { cookies } = await import('next/headers');
      const cookieStore = await cookies();
      return cookieStore.toString();
    } catch (error) {
      console.error('Error getting cookies:', error);
      return '';
    }
  }
};

const getCommonHeaders = async (): Promise<Record<string, string>> => {
  const cookieString = await getCookies();

  return {
    'Content-Type': 'application/json',
    'X-Requested-With': 'XMLHttpRequest',
    Cookie: cookieString,
  };
};

const request = async <T extends object>(
  endpoint: string,
  method: string,
  params?: Record<string, unknown>,
): Promise<Result<T, Error>> => {
  const url = getUrl(endpoint, method === 'GET' ? params : undefined);
  let body = null;
  if (method !== 'GET') {
    body = params;
  }
  const headers = await getCommonHeaders();
  try {
    const response = await fetch(url, {
      method,
      headers,
      ...(body ? { body: JSON.stringify(body) } : {}),
    });

    if (!response.ok) {
      const error = new Error(`HTTP status code: ${response.status} ${response.statusText}`);
      return { ok: false, error, response };
    }

    const data = (await response.json()) as T;
    return {
      ok: true,
      value: toCamelCase(processedData as Record<string, unknown>) as T,
      response,
    };
  } catch (error) {
    if (error instanceof Error) {
      return { ok: false, error, response: new Response() };
    }
    // MEMO: typescriptはcatch節での引数の型がunknownになるため、Error型以外はここでthrowする
    throw error;
  }
};

export const api = {
  get: async <T extends object>(
    endpoint: string,
    params?: Record<string, unknown>,
  ): Promise<Result<T, Error>> => request<T>(endpoint, 'GET', params),
  post: async <T extends object>(
    endpoint: string,
    params?: Record<string, unknown>,
  ): Promise<Result<T, Error>> => request<T>(endpoint, 'POST', params)
}
  • 呼び出し方
import { api } from '@/api/api-client';

interface Response {
  user: User;
}

export const getUser = async (id: number): Promise<User[] | undefined> => {
  const result = await api.get<Response>(`/api/v1/user/${id}`);

  if (result.ok) {
    return result.value.user;
  } else {
    console.error(result.error.message);
    return undefined;
  }
};

より快適なNext.js開発のために

本記事では、Next.jsにおけるAPI呼び出しを共通化し、開発体験を向上させるためのAPIクライアント実装をご紹介しました。

このクライアントが目指したのは、以下の実現です。

  • Result<T>型による明確な成功/失敗の表現と、TypeScriptの強力な型推論による型安全性の確保。
  • getやpostメソッドを直接エクスポートすることによる、シンプルで直感的なインターフェース。
  • URL構築や共通ヘッダーの設定、エラーハンドリングといった煩雑な処理の隠蔽。
  • クライアントコンポーネントとサーバーコンポーネントの両方で動作する柔軟性。
  • バックエンドのsnake_caseからフロントエンドのcamelCaseへの自動変換による記述の一貫性。
wwwave's Techblog

Discussion