🗺

Next.js の API Routes から SWR の型推論を導く

11 min read

本記事は 「Recruit Engineers Advent Calendar 2021」 2日目の記事です。

ファイルシステム API Routes の課題

Next.js のファイルシステムを利用した routing は、直感的に定義を追加することができます。一方、モジュールシステム観点からは透過的参照がないため、TypeScript の型推論と相性が悪いです。Next.js における型安全な routing ソリューションとして pathpida がありますが、API Routes には対応していません。

useSWR から API Routes の API を呼ぶシーンで期待に沿うものが見当たらなかったので、今回自作してみました(リポジトリはこちら)本サンプルでは、npm script のpostinstallを hook に、src/types/pages/apiに生成ファイルが出力されるので、あらかじめnpm installを実行してお試しください。

サンプルで実現している型推論概要

はじめに簡単な API Routes の定義を見ていきます。以下の様に、req.queryで渡された値を返却する API を実装しているとします。

// pages/api/greet.ts
const handler = (req, res) => {
  res.status(200).json({ data: { message: `hello ${req.query.name}` } });
};
export default handler;

この API を利用する Client 側の Component を見てみましょう。以下の様に、useSWR関数をラップしたuseApiData関数があります。この実装だけでdata型推論が導かれれば、作業効率が向上しそうですよね? 今回作成したサンプルは、これを実現しています。

エラーが発生した場合のerrorにも型推論が行き届き、query payload も型制約が効いています。Client 側のコードに型情報が与えられていないにも関わらず、このuseApiData関数はここまで型推論を導くことが出来ています。

// pages/index.tsx
const { data, error } = useApiData("/api/greet", { query: { name: "user" } });
// const data: {
//     message: string;
// } | undefined
// const error: {
//     httpStatus: number;
//     message: string;
// } | undefined

別ページの実装を見てみます。同じuseApiData関数を利用していますが、先のdata型推論とは異なる推論が導かれているのが分かります。

// pages/users/index.tsx
const { data } = useApiData("/api/users");
// const data: {
//     users: User[];
// } | undefined

この型推論を導いているのは API Path 相当の第一引数の文字列指定で、全ての API Routes 定義を把握しています。 下のキャプチャはサンプルを VSCode で開いた画面で、API Path を入力しようとした所で補完が表示されている様子が確認できます。

infer api routes type

これがどの様に実装されているのか見ていきましょう。

※ 本サンプルは以下の条件が考慮されていません。あらかじめご了承ください。

共通の正常系・異常系レスポンス型定義

これより、型定義ファイルは types/pages/api配下に作り込んでいきます。はじめに、プロジェクト内共通の正常系・異常系レスポンスの共通型定義をします。以下の型定義があるものとして解説を進めます。

declare module "@/types/pages/api" {
  export interface Data<T> {
    data: T;
  }
  export interface Error {
    error: {
      httpStatus: number;
      message: string;
    };
  }
}

API Routes と Client はこの型定義のもと疎通します。次に、API Routes のハンドラー定義をみてみましょう。

API Routes のハンドラーをメソッド毎に分割する

API Routes のハンドラーは、リクエストメソッドを識別せずにリクエストを捕らえます。SWR においては GET のみで十分ですが、まずは受け取ったリクエストメソッドを起点にハンドラーを分岐します(GETの場合はgetHandler関数に、POSTの場合はpostHandler関数に振り分ける、ということです)期待値外のメソッドが送られてきた場合に備えて、以下の様に switch 文で405を返却しておくと良いです。

// pages/api/greet.ts
const getHandler = (req, res) => {
  res.status(200).json({ data: { message: `hello ${req.query.name}` } });
};
const handler = (req, res) => {
  switch (req.method) {
    case "GET":
      getHandler(req, res);
      break;
    default:
      res
        .status(405)
        .json({ error: { httpStatus: 405, message: "Method Not Allowed" } });
  }
};
export default handler;

これだけでは型を指定していないため、コンパイルエラーとなります。そこで、Next.js から提供されているNextApiRequest型とNextApiResponse型を注釈します。NextApiHandler型を使っても同じ推論が得られます。

// pages/api/greet.ts
import type { NextApiRequest, NextApiResponse, NextApiHandler } from "next";

const getHandler = (req: NextApiRequest, res: NextApiResponse) => {
  res.status(200).json({ data: { message: `hello ${req.query.name}` } });
};
const handler: NextApiHandler = (req, res) => {
  switch (req.method) {
    case "GET":
      getHandler(req, res);
      break;
    default:
      res
        .status(405)
        .json({ error: { httpStatus: 405, message: "Method Not Allowed" } });
  }
};
export default handler;

期待するレスポンス型を注入できるものの、公式から提供されている型定義はここまで。これだけでは型効力の弱さが否めません。

  • レスポンスが期待値と齟齬を起こす可能性がある
  • リクエストの query・body 内訳が定かではない

API ハンドラーの型推論を強化する

NextApiHandler型の推論を強化するため、新しい型定義を設けます(以下ApiHandler型)Omit型で緩い定義を削除し、Generics で期待型を注入できる様に再定義します。resの注釈には、冒頭で定義したData<T>型とError型の Union 型を適用しています。

export type ApiHandler<Q = unknown, B = any, R = unknown> = (
  req: Omit<NextApiRequest, "body" | "query"> & {
    query: Partial<Q>;
    body?: B;
  },
  res: NextApiResponse<Data<R> | Error>
) => void | Promise<void>;

上記 Generics に期待する型定義内訳は以下の通りです。

  • Q: ReqQuery / リクエストの Query 型
  • B: ReqBody / リクエストの Body 型
  • R: ResBody / レスポンスの Body 型

先の実装に、このApiHandler型を使って注釈を適用していきます。3 つの Generics を与えて作成したGetHandler型は、それぞれ順番に Q,B,R の期待型を指定しています。これで/api/greetにおける GET の I/O が明示的になりました。

// pages/api/greet.ts
import type { ApiHandler } from "@/types/pages/api";

export type GetHandler = ApiHandler<{ name: string }, {}, { message: string }>;

const getHandler: GetHandler = (req, res) => {
  if (!req.query.name) {
    res
      .status(400)
      .json({ error: { httpStatus: 400, message: "Invalid Request" } });
    return;
  }
  res.status(200).json({ data: { message: `hello ${req.query.name}` } });
};
const handler: ApiHandler = (req, res) => {
  switch (req.method) {
    case "GET":
      getHandler(req, res);
      break;
    default:
      res
        .status(405)
        .json({ error: { httpStatus: 405, message: "Method Not Allowed" } });
  }
};
export default handler;

他にも実装するリクエストメソッドハンドラーに応じて、PostHandler型やPutHandler型を同様に定義します。次に、この宣言された型定義を「一箇所に集約」し、引き出せる様にしていきます。

宣言結合による型定義集約

型定義において、typeを用いた定義とinterfaceを用いた定義の大きな違いの一つに 「宣言結合」 があります。これは、同一宣言空間において同名の型定義はマージされるというものです。以下が簡単な例です。

interface ResBody {
  a: "a";
}
interface ResBody {
  b: "b";
}

この型定義はtypeを使用した時と異なりエラーにはならず、以下型定義と同じものとして扱うことができます。

interface ResBody {
  a: "a";
  b: "b";
}

この性質を利用して、API Routes に定義されている全ての GET レスポンス Body 型をGetResBody型に集約します。定義ファイルが異なっていてもdeclare moduleの宣言空間が一致している場合、宣言結合が発生します。

import type { GetHandler } from "@/pages/api/greet";
declare module "@/types/pages/api" {
  interface GetResBody {
    "/api/greet": ResBody<GetHandler>;
  }
}
import type { GetHandler } from "@/pages/api/users";
declare module "@/types/pages/api" {
  interface GetResBody {
    "/api/users": ResBody<GetHandler>;
  }
}

この定義は以下と同じで、利用時に API Routes の「パス文字列」を指定することで、レスポンスを選ぶことが可能になります。

declare module "@/types/pages/api" {
  interface GetResBody {
    "/api/greet": ResBody<GreetGetHandler>;
    "/api/users": ResBody<UsersGetHandler>;
  }
}

サンプルのpostinstallで出力されるsrc/types/pages/api/**/*.d.tsファイル群は、この宣言結合を狙って生成・集約されています。

型の Lookup を利用した導出

TypeScript の関数では、引数の文字列リテラルから、型の Lookup が可能です。以下の簡単な例で確認していきます。はじめに Data型を定義、fn関数の Generics に<T extends keyof Data>と定義します。

すると、fn関数第一引数keydataが保持するプロパティの key 文字列のみに制約することができます。そしてレスポンスの型は、指定された文字列から型定義を辿り、それぞれ"a""b"の推論を導くことができます。

type Data = {
  a: "a";
  b: "b";
};
const data: Data = {
  a: "a",
  b: "b",
};
function fn<T extends keyof Data>(key: T) {
  return data[key];
}
const isA = fn("a"); // const isA: "a"
const isB = fn("b"); // const isB: "b"

この性質を利用し、useSWRのラッパー関数useApiDataを定義します(冒頭で紹介した custom hooks です)今回のサンプルでは Native の fetch 関数を利用していますが、fetcher は axios でも何でも構いません。error のリスローなども行っており、この関数内で共通の処理を施すと都合が良いです。

export function useApiData<
  T extends keyof GetResBody,
  ReqQuery extends GetReqQuery[T],
  ResBody extends GetResBody[T]
>(
  key: T,
  {
    query,
    requestInit,
    swrConfig,
  }: {
    query?: ReqQuery;
    requestInit?: RequestInit;
    swrConfig?: SWRConfiguration;
  } = {}
) {
  const url = query ? `${key}?${qs.stringify(query)}` : key;
  return useSWR<ResBody, Error["error"]>(
    url,
    async (): Promise<ResBody> => {
      const { data, error } = await fetch(url, requestInit).then((res) =>
        res.json()
      );
      if (error) throw error;
      return data;
    },
    swrConfig
  );
}

T型はレスポンスの Body 型が集約されたGetResBody型の key を参照していますから、"/api/greet""/api/users"などの API Path 文字列で絞り込むことが出来る様になりました。SWR の cache key に相当する文字列も、受け取った query オブジェクトを stringify し付加しています。

型定義の自動生成と宣言結合

さて、ここまでの定義で以下の課題を解決しました。

  • API Routes ハンドラーの型安全強化
  • API Routes を利用する fetch client(SWR) の型安全
  • 双方型定義の連動と齟齬防止

残る課題は「手間」の簡略化です。例えば、GET・PUT・DELETEを備えた API Routes は、宣言結合のために以下のような冗長なマッピング作業を手動で行わなければいけません。更新漏れであったり、手動作業によるミスが懸念されるため、この様な定型的作業は自動で行いたいです。

declare module "@/types/pages/api" {
  interface GetReqQuery {
    "/api/users/[id]": ReqQuery<GetHandler>;
  }
  interface PutReqQuery {
    "/api/users/[id]": ReqQuery<PutHandler>;
  }
  interface DeleteReqQuery {
    "/api/users/[id]": ReqQuery<DeleteHandler>;
  }
  interface GetReqBody {
    "/api/users/[id]": ReqBody<GetHandler>;
  }
  interface PutReqBody {
    "/api/users/[id]": ReqBody<PutHandler>;
  }
  interface DeleteReqBody {
    "/api/users/[id]": ReqBody<DeleteHandler>;
  }
  interface GetResBody {
    "/api/users/[id]": ResBody<GetHandler>;
  }
  interface PutResBody {
    "/api/users/[id]": ResBody<PutHandler>;
  }
  interface DeleteResBody {
    "/api/users/[id]": ResBody<DeleteHandler>;
  }
}

ここで活用できるのが、TypeScript Compiler API です。規定名称として GetHandler型やPutHandler型を export していることを頼りに、API Routes で定義されいる ApiHandler型を収集します。そこから Node.js のファイルシステム API で import path などを解決し、マッピング型定義ファイルを生成します。

このマッピング型定義ファイルの生成は、AST Factory 関数をもって構築しており、詳細はサンプル実装をご確認ください。API Routes の変更を監視し生成ファイルの追加・削除を行えば、コーディングをしながら型定義を反映させることも可能になるでしょう。

まとめ

本サンプルでは、更新用の fetch 関数にも適用しており、実装はこちらで確認できます。生成した型定義は axios など任意の fetch client で利用できますし、型定義を紐づけているだけなので、ランタイムも汚しません。

  • TS Compiler API & Node.js
  • Conditional Types
  • 型の Lookup
  • 同一宣言空間における型の宣言結合

これらの機能をフル活用することで、ファイルシステムベースを利用するフレームワークでも、快適な型推論を叶えられることを紹介しました。

Discussion

ログインするとコメントできます