📨

期待するResponseの型をRequestにつける (Fetch API)

2023/12/08に公開

これがやりたい

  • レスポンスステータスで分岐して、ボディに型が付く
  • fetch() の呼び出しを隠蔽しない
const request = new Request(url) as DetailedRequest<
  | DetailedResponse<200, { userId: number }>
  | DetailedResponse<400, { message: string }>
>;

const response = await(fetch as DetailedFetch)(request);
// : DetailedResponse<200, { userId: number }>
//   | DetailedResponse<400, { message: string }>

switch (
  response.status // : 200 | 400
) {
  case 200:
    const result = await response.json();
    result.userId; // : number
    break;

  case 400:
    const result = await response.json();
    result.message; // : string
    break;
}

やりました

3つの型定義をつくりました。ゼロランタイムです。

// `Response` を拡張し、HTTPステータスとボディの型の対応を作る。
interface DetailedResponse<Status extends number, Body> extends Response {
  status: Status;
  json: () => Promise<Body>;
}

// `Request` を拡張し、期待する `Response` の型を持たせる。
interface DetailedRequest<ExpectedResponse extends Response> extends Request {}

// `DetailedRequest<T>` を与えると `T` が返ってくる Fetch API の型。
type DetailedFetch = <
  TRequest extends DetailedRequest<TResponse>,
  TResponse extends Response = TRequest extends DetailedRequest<infer U>
    ? U
    : never,
>(
  request: TRequest,
  init?: RequestInit,
) => Promise<TResponse>;

なぜやったのか

スキーマ駆動に魅せられて。
OpenAPIドキュメントからTypeScriptコードを自動生成するスクリプトを作る職人さんを生業としています(自嘲)。

これまで自分が作ってきたものは、any な戻り値に as アサーションを行う単純な型付けアプローチ(後述)を使って、Fetch APIをラップしたランタイムコードを生成するものでした。
ラップした結果、インターフェースはFetch APIのものとは異なってしまい、オレオレaxiosやオレオレkyを作っているようなものですから、まあ良くないでしょう。
よ〜く読めば難しくないコードかもしれませんが、初見ではギョッとする。そんな感じです。

こちらの記事を読みました。

https://zenn.dev/micin/articles/openapi-typescript-with-type-puzzles

ランタイムコードは生成したくないと思っているので、OpenAPIドキュメントから型定義だけを生成するというライブラリopenapi-typescriptは自分にピッタリでした。

https://github.com/drwpow/openapi-typescript

これを利用したエレガントなコードを試行錯誤しているうちにたどり着いたのがこれ(先述)でした。

活用例

いま自分が関わっているプロジェクトでの例です。

まずは、前節で挙げた openapi-typescript を使って、お手元のOpenAPIドキュメントから型情報を作ります。

その中から、 DetailedRequest<> の型引数に与える型を抜き出し、次のようにアサーションしてやります。

const request = new Request("/users/123?foo=bar", { method: "PUT" })
  as DetailedRequest</* 生成した型をここに入れる(割愛) */>;

実際にやってみるとわかりますが、このままでは型引数部分がとんでもないことになります。
メソッドとパスから DetailedRequest を作るようなutil型を用意すると良いでしょう。

const request = new Request("/users/123?foo=bar", { method: "PUT" })
  as DetailedRequestOf<"put", "/users/{userId}">;

この形ではメソッドとパスを二重に書く必要があるというのと、 openapi-typescript はリクエストに関する型定義も生成してくれますから、次のようなutil関数を用意するとよいでしょう。

const createDetailedRequest = <Method, Path>(
  method: Method,
  path: Path,
  pathParams: PathParamsOf<Method, Path>,
  queryParams: QueryParamsOf<Method, Path>,
  body: BodyOf<Method, Path>,
): DetailedRequestOf<Method, Path> => {
  /* 頑張って作る(割愛) */
};

const request = createDetailedRequest(
  "put",
  "/users/{userId}",
  { userId: 123 },
  { foo: "bar" },
  { name: "baz" },
);

用意すると良いでしょうなんて軽く書きましたが、実際やってみると型パズルはとんでもないことになるし、パラメータの組み立て処理もそこそこゴツいです。
主題がブレるので、この記事では詳しく書かないでおきます。

さいごに

最後に類似のアプローチを紹介させてください。

https://zenn.dev/mizchi/articles/typed-fetch-magic

こちらのやり方と合わせて、もっと良くできないか改めて考えてみることにします。

Appendix

単純な型付けアプローチ

.json() の戻り値は Promise<any>

const result = await response.json();

result; // : any
result.foo; // : any

ここに as アサーションを付ける。
単純で、おそらく一般的なアプローチ。

const result = (await response.json()) as { foo: number };

result.foo; // : string
GitHubで編集を提案

Discussion