Next.jsでaxiosへの型付けとエディタ補完をする
はじめに
フロントエンドからAPIをコールする時に、型やエディタでの補完がなくて辛いなと思いました。良さげなライブラリは見つけられなかったので、以下の記事を参考に補完を効かせつつ、パラメータなどに型が付くようにします。
前提
- APIのコールには
fetch
ではなくaxois
を使う - バックエンドのAPIサーバーは
TypeScript
以外 -
OpenAPI
を使っていない
環境
- macOS: 13.5
- Next.js: 13.4
動作サンプル
以下はPyCharmで動かした時の補完の挙動の例です。パスに関する補完が効いていることがわかります。
また、パラメータに関しても同様に補完が効きます。
パラメータが存在しない場合にはエラーも出してくれます。
コード
コードは以下のリポジトリに置いてあります。適宜参考にしてください。
準備
特に意味はないですがアプリの作成から行います。AppRouter
は利用せずに、PagesRouter
を利用します。
$ npx create-next-app@latest
Need to install the following packages:
create-next-app@13.4.19
Ok to proceed? (y) y
✔ What is your project named? … zenn-app
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias? … No / Yes
アプリの作成が終わったら必要なライブラリをインストールします。
# それぞれ必要なパッケージをインストール
$ npm install type-fest axios swr query-string
$ npm install prettier -D
環境変数に型をつけるため以下のファイルをディレクトリ直下に作成します。こうすることで、環境変数を参照するときにnull
かどうかを考慮しなくてよくなります。
declare namespace NodeJS {
// 環境変数名の定義
interface ProcessEnv {
readonly NEXT_PUBLIC_BACKEND_API_ENDPOINT: string;
}
}
そして、以下の環境変数ファイルを作成します。
NEXT_PUBLIC_BACKEND_API_ENDPOINT=http://localhost:3000
最後に、API Routes
を利用してあらかじめ対応する検証用のAPIを作成します。pages/api
以下にファイルやフォルダを作成すると、簡易的なAPIサーバーが作れます。この例では、http://localhost:3000/api/user
にブラウザからアクセスすると、{ status: "success", id: "1", name: "Alice" }
の結果を取得できます。
import type { NextApiRequest, NextApiResponse } from "next";
type Data = {
status: string;
id: string;
name: string;
};
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Data>,
) {
if (req.method === "GET") {
res.status(200).json({ status: "success", id: "1", name: "Alice" });
} else if (req.method === "POST") {
res.status(200).json({ status: "success", id: "2", name: "Bob" });
} else if (req.method === "PUT") {
res.status(200).json({ status: "success", id: "3", name: "Eve" });
} else if (req.method === "DELETE") {
res.status(200).json({ status: "success", id: "4", name: "Mallory" });
}
}
テスト用の様々なパスのリクエストを受け付けられるように、以下のファイルを作成します。この例では、http://localhost:3000/api/v1/a/b/c
やhttp://localhost:3000/api/v1/abc/def
にアクセスすると、{ status: "success", id: "1", name: "Alice" }
の結果を取得できます。
詳しくは以下の公式ドキュメントを確認してください。
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const slugs = req.query.slug || ["/"];
const url: string =
typeof slugs === "string" ? `/${slugs}` : `/${slugs.join("/")}`;
console.log(
`method: ${req.method}, query: ${JSON.stringify(req.query)}, url: ${url}`,
);
if (req.method === "GET") {
res.status(200).json({ status: "success", id: "1", name: "Alice" });
} else if (req.method === "POST") {
res.status(200).json({ status: "success", id: "2", name: "Bob" });
} else if (req.method === "PUT") {
res.status(200).json({ status: "success", id: "3", name: "Eve" });
} else if (req.method === "DELETE") {
res.status(200).json({ status: "success", id: "4", name: "Mallory" });
}
}
これで準備は完了です。これから型を定義していきます。
バックエンドのAPIの型定義
まずバックエンドのAPIの型の定義をします。二度手間感はあるのですが、これをしないと型の補完が効かせられないためです。OpenAPIなどを使っている場合には、この記事(再掲)を読んだ方が幸せになると思います。
以下のAPIの型を定義するためのtypeやinterfaceを作成します。
import type { NextApiRequest, NextApiResponse } from "next";
export interface Data<T> {
data: T;
}
export interface Error {
error: {
httpStatus: number;
message: string;
};
}
export type ApiParams<P = any, Q = any, B = any, R = any> = (
// 正確には`path`をOmitする必要はないですが、今後のことも考えてこうしています。
req: Omit<NextApiRequest, "body" | "query" | "path"> & {
query: Partial<Q>;
body?: B;
path?: P;
},
res: NextApiResponse<Data<R> | Error>,
) => void | Promise<void>;
export type ExtractReqQuery<T> = T extends ApiParams<any, infer I, any, any>
? I
: never;
export type ExtractReqData<T> = T extends ApiParams<any, any, infer I, any>
? I
: never;
export type ExtractResData<T> = T extends ApiParams<any, any, any, infer I>
? I
: never;
export type ExtractPathParams<T> = T extends ApiParams<infer I, any, any, any>
? I
: never;
ここで共通の型を定義しています。
export interface Data<T> {
data: T;
}
export interface Error {
error: {
httpStatus: number;
message: string;
};
}
そして、ここでAPIのパラメータをそれぞれ定義できるようにするための型を定義しています。
export type ApiParams<P = any, Q = any, B = any, R = any> = (
req: Omit<NextApiRequest, "body" | "query" | "path"> & {
query: Partial<Q>;
body?: B;
path?: P;
},
res: NextApiResponse<Data<R> | Error>,
) => void | Promise<void>;
// 後述しますが以下のような利用を想定しています。
// export type GetUserHandler = ApiParams<
// {}, // パスパラメータ
// { id: string }, // リクエストquery
// {}, // リクエストbody
// { status: string; name: string } // レスポンスbody
// >;
次に、上で定義したApiParams
を使ってバックエンドのAPIの型定義を書きます。例として、GET
, POST
, PUT
, DELETE
の定義を書いてみます。
import { ApiParams } from "@/common/types/api";
type GetUserHandler = ApiParams<
{},
{ id: string },
{},
{ status: string; id: string; name: string }
>;
type PostUserHandler = ApiParams<
{},
{},
{ name: string },
{ status: string; id: string; name: string }
>;
type PutUserHandler = ApiParams<
{},
{},
{ id: string; name: string },
{ status: string; id: string; name: string }
>;
type DeleteUserHandler = ApiParams<
{},
{},
{ id: string },
{ status: string; id: string; name: string }
>;
type GetUserPathParamHandler = ApiParams<
{ userId: string },
{ id: string },
{},
{ status: string; id: string; name: string }
>;
export interface paths {
// これらのメソッドは、`/user`というパスに対応しているという想定
"/user": {
GET: GetUserHandler;
POST: PostUserHandler;
PUT: PutUserHandler;
DELETE: DeleteUserHandler;
};
// 補完の例として適当なパスを追加(/v1/[...slug].tsで受ける)
"/v1/user/book": {
GET: GetUserHandler;
};
"/v1/user/payment": {
GET: GetUserHandler;
};
"/v1/user/balance": {
GET: GetUserHandler;
};
"/v1/user/{userId}/balance": {
GET: GetUserPathParamHandler;
};
}
axiosのクライアント作成
に書かれているaxiosのApiClient
を利用します。
import axios from "axios";
// 共通ヘッダー
const headers = {
"Content-Type": "application/json",
};
// axiosの初期設定
export const BackendApiClient = axios.create({
baseURL: `${process.env.NEXT_PUBLIC_BACKEND_API_ENDPOINT}/api`,
headers,
});
// レスポンスのエラー判定処理
BackendApiClient.interceptors.response.use(
(response) => {
return response;
},
(error) => {
console.log(error);
switch (error?.response?.status) {
case 401:
break;
case 404:
break;
default:
console.log("== internal server error");
}
const errorMessage = (error.response?.data?.message || "").split(",");
throw new Error(errorMessage);
},
);
初期設定のURLは準備のところで設定した環境変数を用います。このApiClient
を利用することで毎回設定するURL
やheaders
の定義が1回で済みます。
// axiosの初期設定
export const BackendApiClient = axios.create({
baseURL: `${process.env.NEXT_PUBLIC_BACKEND_API_ENDPOINT}/api`,
headers,
});
APIの型補完を効かせる
フロントのコーディングをする際の補完を効かせて行きます。
この記事(再掲)の内容がほとんどそのまま出てきます。
まずは、APIを呼ぶ関数の補完を効かせるための型を定義します。
import { paths } from "./types";
import { UnionToIntersection, Get } from "type-fest";
import {
ExtractPathParams,
ExtractReqQuery,
ExtractReqData,
ExtractResData,
} from "@/common/types/api";
export type UrlPaths = keyof paths;
export type HttpMethods = keyof UnionToIntersection<paths[keyof paths]>;
export type HttpMethodsFilteredByPath<Path extends UrlPaths> = HttpMethods &
keyof UnionToIntersection<paths[Path]>;
export type RequestPathParameters<
Path extends UrlPaths,
Method extends HttpMethods,
> = ExtractPathParams<Get<paths, `${Path}.${Method}`>>;
export type RequestParameters<
Path extends UrlPaths,
Method extends HttpMethods,
> = ExtractReqQuery<Get<paths, `${Path}.${Method}`>>;
export type RequestData<
Path extends UrlPaths,
Method extends HttpMethods,
> = ExtractReqData<Get<paths, `${Path}.${Method}`>>;
export type ResponseData<
Path extends UrlPaths,
Method extends HttpMethods,
> = ExtractResData<Get<paths, `${Path}.${Method}`>>;
以下の箇所でpaths
のキーであるAPIのパスと、メソッドの対応関係を取得します。
export type UrlPaths = keyof paths;
export type HttpMethods = keyof UnionToIntersection<paths[keyof paths]>;
export type HttpMethodsFilteredByPath<Path extends UrlPaths> = HttpMethods &
keyof UnionToIntersection<paths[Path]>;
そして、Path
とMethod
をキーに指定してpaths
からGet<>
で対応するAPIを取得し、さらにそのAPIのinterface
からExtractReqQuery<>
でリクエストの型を取得しています。Get<>
はtype-fest
で、ExtractReqQuery<>
は上で定義したユーティリティです。
export type RequestParameters<
Path extends UrlPaths,
Method extends HttpMethods,
> = ExtractReqQuery<Get<paths, `${Path}.${Method}`>>;
最後にaxios
やswr
を利用するための関数を記述します。
import { AxiosError, AxiosResponse, AxiosRequestConfig } from "axios";
import useSWR from "swr";
import {
HttpMethods,
HttpMethodsFilteredByPath,
RequestPathParameters,
RequestData,
RequestParameters,
ResponseData,
UrlPaths,
} from "@/lib/api/schemaHelper";
import { BackendApiClient } from "./clients";
export type AxiosConfigWrapper<
Path extends UrlPaths,
Method extends HttpMethods,
> = {
url: Path;
method: Method & HttpMethodsFilteredByPath<Path>;
paths: RequestPathParameters<Path, Method>;
params?: RequestParameters<Path, Method>;
data?: RequestData<Path, Method>;
};
export function request<Path extends UrlPaths, Method extends HttpMethods>(
config: AxiosConfigWrapper<Path, Method>,
) {
const { url, paths, ...baseConfig } = config;
const requestConfig: AxiosRequestConfig = {
...baseConfig,
url: Object.entries(paths ?? {}).reduce(
(previous, [key, value]) =>
previous.replace(new RegExp(`\\{${key}\\}`), String(value)),
url as string,
),
};
return BackendApiClient.request<
ResponseData<Path, Method>,
AxiosResponse<ResponseData<Path, Method>>,
AxiosConfigWrapper<Path, Method>["data"]
>(requestConfig);
}
const fetcher = <Path extends UrlPaths, Method extends HttpMethods>(
config: AxiosConfigWrapper<Path, Method>,
) => {
return request<Path, Method>(config).then((res) => res.data);
};
export const useAppSWR = <Path extends UrlPaths, Method extends HttpMethods>(
config: AxiosConfigWrapper<Path, Method>,
) => useSWR<ResponseData<Path, Method>, AxiosError>(config, fetcher);
これで完成しました。ただ、request
の方はカスタムフックにはなっていないので、以下の記事などを参考に必要に応じてカスタムフックにすると良いかと思います。
動作確認
作成したSWRのフックを使ってGET
リクエストを行ってみます。パスパラメータの{userId}
も引数と対応させたURLにアクセスしています。
// (...前略)
export default function Home() {
const { data, mutate: reFetch } = useAppSWR({
url: "/v1/user/{userId}/balance",
method: "GET",
params: { id: "any" },
paths: { userId: "1" },
});
// (...後略)
そのほかのPOST
などのリクストも同じような結果を得られます。
// (...前略)
<button
className="(省略)"
rel="noopener noreferrer"
onClick={() => {
request({
url: "/user",
method: "POST",
data: {
name: "new-name",
},
paths: {},
}).then((res) => console.log(res.data.name));
}}
>
// (...後略)
おわりに
フロントエンドからバックエンドのAPIを実装する際に、型を付けたりエディタ補完が効くようになって安心できました。
そのほかの参考記事
Discussion