🛡

Aspida で型安全な MSW ハンドラーを書く

2022/04/21に公開

MSW のハンドラー型推論

テストや Storybook で利用事例が増えている MSW。GraphQL の場合、@graphql-codegen/typescript-mswというプラグインを使えば、良い感じの MSW ハンドラーを生成してくれます。これがあれば、MSW ハンドラー関数起因のミス削減が期待できます。

query GetUser($id: ID!) {
  getUser(id: $id) {
    name
    id
  }
}
import { mockGetUserQuery } from "./generated";

const worker = setupWorker(
  mockGetUserQuery((req, res, ctx) => {
    // id の型推論が効いている
    const { id } = req.variables;
    return res(
      ctx.data({
        // 型制約が効いており、スキーマに沿ったレスポンスしか返せない
        getUser: { name: "John Doe", id: id },
      })
    );
  })
);

worker.start();

mock<OperationName><OperationType>[LinkName]といった命名規則で、ハンドラーファクトリー関数が generate されます。詳細は以下。

https://www.graphql-code-generator.com/plugins/typescript-msw

REST x Aspida で同等の型安全を実現する

REST API の場合、OpenAPI から MSW ハンドラー関数を生成してくれるエコシステムは、筆者の知る限りでは今のところありません。そこで、Aspidaと型パズルでなんとかならないかな?と検証したらすんなり出来たので紹介します。

POST:/v1/my_apiという API が定義されている場合、以下の様なコードになります。"../api/$api"は自動生成コードに相当し、OpenAPI 以外に型情報を書く必要はありません。

import aspida from "@aspida/axios";
import api from "../api/$api";
import { restPost } from "./handlerFactory";
const client = api(aspida());

export const handlers = [
  // restPost という、POST用のファクトリー関数をつかう。
  // 第一引数に AspidaClient の該当 API を渡す。
  // 第一引数に応じて、第二引数のリゾルバー関数推論が変わる。
  restPost(client.v1.my_api, (req, res, ctx) => {
    // name の型推論が効いている
    const { name } = req.body;
    // ctx.json は { message: string } しか返せない
    return res(ctx.json({ message: `hello ${name}` }));
  }),
];

メリットをまとめると次の通りです。

  • 型注釈する必要がなく、スッキリ書ける
  • 型注釈する必要がなく、OpenAPI の内容が型推論に適用される
  • API Path と Request/Response が紐づいているため、マッピングでミスがない
  • ランタイムで実際に使う、AspidaClient と同じものを使える

ハンドラーファクトリー関数の型推論

先の例に示したrestPostというハンドラーファクトリー関数。型定義が込み入っているので、ひとまず JavaScript で表現した場合の内訳を見ていきます。第一引数のapiは、AspidaClient の API を指しています。api.$path関数で API path を取得できるので、MSW のrest.post第一引数にはこれを与えます。rest.post第二引数のresolverは、与えられた resolver 関数をそのままバインドします。非常に簡単な関数ですね。

import { rest } from "msw";

export function restPost(api, resolver) {
  return rest.post(api.$path(), resolver);
}

このハンドラーファクトリー関数に型推論をかけていきます。「与えられた第一引数に応じて、第二引数の推論を導く」という点がポイントです。

import { rest, ResponseResolver, RestContext, RestRequest } from "msw";
type A1<T> = T extends (a1: infer I) => unknown ? I : never;
//
// Aspida で生成される Methods型を模倣したもの。型をキャプチャするために用意
//
type Method = {
  reqHeaders: any;
  query: any;
  status: number;
  resBody: any;
  reqBody: any;
};
type MethodNames = "get" | "post" | "put" | "patch" | "delete";
type Methods = { [K in MethodNames]: Method };
//
// restPost 関数第一引数の期待値(型)
//
type Post = {
  post: (option: {
    body: Methods["post"]["reqBody"];
    query: Methods["post"]["query"];
    config?: any;
  }) => Promise<Methods["post"]["resBody"]>;
  $path: () => string;
};
//
// 型の lookup で第二引数の resolver に型注釈を与える
//
export function restPost<T extends Post>(
  api: T,
  resolver: ResponseResolver<
    RestRequest<A1<T["post"]>["body"]>,
    RestContext,
    Awaited<ReturnType<T["post"]>>["body"]
  >
) {
  return rest.post(api.$path(), resolver);
}

あとは必要に応じて Http メソッドに対応するハンドラーファクトリー関数を用意すれば OK。便利な Aspida から型情報を吸い上げる作例でした。

Discussion