🐱

Effect-ts による WebAPI 実装に触れてみよう

に公開

最近、Effect-ts を触っています(正式名称は Effect ですが googlability が低いと専らの評判なので、慣例に習って Effect-ts と表記します)。

https://effect.website/

型安全性が強く、エラーや依存が型として追跡される点、関数型のパラダイムをベースとする思想などが気に入っています。
この Effect-ts を使って WebAPI を実装してみるとどうなるかという雰囲気を伝えてみる記事です。

effect/platform による WebAPI 実装の特徴

Effect-ts は TypeScript におけるエコシステムや標準ライブラリのような立ち位置を目指しているようで、コア機能とその周辺のパッケージによって成り立っています。
WebAPI用の機能はコアには含まれておらず、effect/platform パッケージを使用します。また実際にWebサーバとして動作させるためには、effect/platform-node といった、ランタイムに対応するパッケージも必要です。

effect/platform を用いた WebAPI の実装では、定義と実装を明確に分けるように書きます。

旧来のMVCフレームワークなどであれば、Controller や Handler に実装した内容がそのまま WebAPI のインタフェースとなることが多いと思います。そのため、クライアント側でサーバー側のAPI実装に沿わない Request を送ってしまっても、実行時まで誤りに気づけないこともしばしばです。
従来はこれを回避するために共通のAPI定義を OpenAPI を用いて用意し、自動生成なども絡めながらクライアント側とサーバー側のコードをそれに一致させるという手段を取ることが多かったと思います。
しかし、型システムを利用して早期に誤りを発見できる方が開発体験がより良くなるのは明白と言って良いでしょう。そのために登場したのが tRPC のような技術です。

effect/platform では、Schema という Effect-ts のコアモジュールに含まれる[1]、データ型や型変換を定義する機能を使ってAPI定義をまず記述し、それに従って実装を書くという手順を踏みます。このため、定義に対する実装がない、Request や Response の型が定義に沿っていないなどのミスは全てコンパイルエラーとして報告されるようになります。もちろん同じ定義をクライアントコードとしても利用できたり、OpenAPI(Swagger)によるAPI定義の文書化も可能です。
単一のAPI定義が「信頼できる唯一の情報源(single source of truth)」として型安全に機能することで、メンテナンス性が向上します。

API定義

effect/platform では、APIエンドポイントの集合をフラットに並べるのではなく、HttpGroup にまとめ、それを最終的な HttpApi にまとめるという方法を取ります。

HttpApi
├── HttpGroup
│   ├── HttpEndpoint
│   └── HttpEndpoint
└── HttpGroup
    ├── HttpEndpoint
    ├── HttpEndpoint
    └── HttpEndpoint

例えばシンプルなユーザーの取得(GET: /users/:userId)と作成(POST: /users)を定義してみます。

import {
  HttpApi,
  HttpApiEndpoint,
  HttpApiGroup
} from "@effect/platform";
import { InternalServerError, NotFound } from "@effect/platform/HttpApiError";
import { Schema } from "effect";

export class User extends Schema.Class<User>("User")({
  id: Schema.Number,
  name: Schema.String,
  created_at: Schema.Date,
}) {}

const getUser = HttpApiEndpoint.get("getUser", "/users/:userId")
  .setPath(Schema.Struct({
    userId: Schema.NumberFromString,
  }))
  .addError(NotFound)
  .addError(InternalServerError)
  .addSuccess(User)

const createUser = HttpApiEndpoint.post("createUser", "/users")
  .setPayload(Schema.Struct({
    name: Schema.String
  }))
  .addError(InternalServerError)
  .addSuccess(User, {status: 201});

HttpApiEndpoint を使った定義では、共通して Name("getUser", "createUser") と Path を引数に取ります。Name はAPI定義を参照する際に使用されます。
setPath でパスパラメータ、setPayloadでリクエストボディを定義します。
addSuccessaddErrorによるレスポンスの定義はもちろん複数追加することが可能です。返し得る全ての Response の型をここに定義しましょう。

HttpApiEndpoint を定義したら、それを HttpApiGroup にまとめ、HttpApi に渡せば定義は完了です。
HttpApiGroup.make の引数は identifer であり、これも後から参照する際に必要になります。

// ...

class UserGroup extends HttpApiGroup.make("user")
  .add(getUser)
  .add(createUser)
{}

export class ServerApi extends HttpApi.make("server-api").add(UserGroup) {}

API実装

API定義が書けたら、対応する実装を用意します。ここでは、createUser の実装を書いてみましょう。(ServerApiUserは先ほどの定義です)

import { ServerApi, User } from "@local/api";

export const UserGroupLive = HttpApiBuilder.group(
  ServerApi,
  "user",
  (handlers) =>
    handlers
      .handle("createUser", ({ payload }) =>
        Effect.gen(function* () {
          const sql = yield* SqlClient.SqlClient;

          const InsertUser = yield* SqlResolver.ordered("InsertUser", {
            Request: User.pipe(Schema.omit("id", "created_at")),
            Result: User,
            execute: (requests) =>
              sql`INSERT INTO "user" ${sql.insert(requests)} RETURNING *`,
          });

          return yield* InsertUser.execute({ name: payload.name });
        }).pipe(
          Effect.mapError((_error) => new InternalServerError())
        )
      )
      .handle("getUser", ({ path }) =>
        // ...
      )
);

この実装では、userグループの "createUser" を参照しており、先ほどのAPI定義が型定義として働くため、payload{ readonly name: string; } になりますし、成功時のレスポンスは User 型でないとコンパイルが通りません。

また、ハンドラの中で定義した InsertUser は以下のような型シグネチャで表されます。

const InsertUser: SqlResolver.SqlResolver<"InsertUser", {
    readonly name: string;
}, User, ResultLengthMismatch | SqlError, never>

Userは成功時の戻り値の型ですが、それと並んで ResultLengthMismatch | SqlError という型が含まれています。これがすなわち、この処理が返す可能性のあるエラーの型です。このエラーの型は何らかの形で処理されない限り伝播し続けます。このように、Effect を使えばエラーの可能性を型として追跡し続けることが可能です。そして、API定義で想定されていないエラーが返る可能性が残っている場合、やはりコンパイルは通りません。
ここでは簡単のために、API定義で返すエラーレスポンスを InternalServerError だけにしているので、Effect.mapErrorを用いてどのエラーも一律で InternalServerError に変換していますが、エラーの型に応じて処理を分けることももちろん可能です。
このようにエラーの可能性も含めて型で厳密に扱えるのが Effect-ts の魅力の1つです。

所感

今回は WebAPI 実装にフォーカスしましたが、Effect-ts はこれ以外にも多様な機能やモジュールを持っており、非常に良い開発者体験を提供してくれます。実行時エラーに悩まされた経験のある人はぜひ一度触ってみてください。

脚注
  1. ver.3.10以降。それ以前は effect/schema というモジュールに分けられていました。https://effect.website/blog/releases/effect/310/ ↩︎

Discussion