期待するResponseの型をRequestにつける (Fetch API)
これがやりたい
- レスポンスステータスで分岐して、ボディに型が付く
-
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を作っているようなものですから、まあ良くないでしょう。
よ〜く読めば難しくないコードかもしれませんが、初見ではギョッとする。そんな感じです。
こちらの記事を読みました。
ランタイムコードは生成したくないと思っているので、OpenAPIドキュメントから型定義だけを生成するというライブラリ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" },
);
用意すると良いでしょうなんて軽く書きましたが、実際やってみると型パズルはとんでもないことになるし、パラメータの組み立て処理もそこそこゴツいです。
主題がブレるので、この記事では詳しく書かないでおきます。
さいごに
最後に類似のアプローチを紹介させてください。
こちらのやり方と合わせて、もっと良くできないか改めて考えてみることにします。
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
Discussion