Elysia: サーバーの型定義をそのままクライアントの型として使う

  • Elysia
  • TypeScript
  • Bun

REST API のクライアントを書くとき、レスポンスの型はサーバーのコードを見て手で写すか、OpenAPI からコード生成して用意する。どちらの方法でも、サーバーを変更した時点で古くなる。Elysia では、サーバーの app の型をクライアント側に import するだけで、パス、パラメータ、ボディ、ステータスごとのレスポンスの型がコード生成なしに得られる1。

Elysia は Bun 向けに設計された backend フレームワークだが、WinterTC 準拠のため Node.js、Deno、Cloudflare Workers でもアダプター経由で動作する2。

サーバー

ユーザーを 1 件返す GET と、1 件作成する POST を定義する。

// server.ts
import { Elysia, t } from "elysia";

const users = new Map<number, { id: number; name: string }>([[1, { id: 1, name: "alice" }]]);

export const app = new Elysia({ prefix: "/api" })
  .get(
    "/users/:id",
    ({ params, status }) => {
      const user = users.get(params.id);
      if (!user) return status(404, { message: "not found" });
      return user;
    },
    {
      params: t.Object({ id: t.Number() }),
      response: {
        200: t.Object({ id: t.Number(), name: t.String() }),
        404: t.Object({ message: t.String() }),
      },
    },
  )
  .post(
    "/users",
    ({ body }) => {
      const id = users.size + 1;
      const user = { id, ...body };
      users.set(id, user);
      return user;
    },
    {
      body: t.Object({ name: t.String({ minLength: 1 }) }),
    },
  );

export type App = typeof app;

t は Elysia に同梱された TypeBox で、ここで書いたスキーマは実行時のバリデーションと、ハンドラ内の params.id や body.name の型付けの両方に使われる3。:id は URL 上では文字列だが、t.Number() を指定したので数値に変換された値がハンドラに届く。status(404, ...) で返した値は、200 とは別のレスポンス型として記録される。

最後の export type App = typeof app が、クライアント側に渡す情報のすべてになる。

クライアント

Eden Treaty に App の型を渡す。

// client.ts
import { treaty } from "@elysiajs/eden";

import type { App } from "./server";

const api = treaty<App>("localhost:3000");

const { data, error } = await api.api.users({ id: 1 }).get();
if (error) {
  console.log(error.status, error.value.message); // 404 のときだけここに来る
} else {
  console.log(data.id, data.name);
}

await api.api.users.post({ name: "bob" });

api.api.users({ id: 1 }).get() は、パス /api/users/:id をそのままプロパティのチェーンに写した呼び出しで、戻り値は { data, error } の組になる1。error がある分岐では error.status が 404、error.value が { message: string } に絞り込まれ、data の分岐では 200 のスキーマの型が付く。

存在しないプロパティの参照も、ボディのキーの打ち間違いも、コンパイル時に検出される。

$ tsc
client.ts(12,20): error TS2339: Property 'email' does not exist on type '{ id: number; name: string; }'.
client.ts(15,28): error TS2353: Object literal may only specify known properties, and 'nmae' does not exist in type '{ name: string; }'.

サーバー側の response スキーマに email を追加すれば、1 つ目のエラーはクライアントを変更しなくても消える。

HTTP を介さずに呼び出す

treaty には URL の代わりに Elysia のインスタンスも渡せる。この場合 fetch は使われず、内部で app.handle(request) が直接呼ばれる。テストでサーバーを起動する必要がなくなり、SSR 中にサーバー自身の API を呼ぶ場合も HTTP の往復が発生しない。

import { app } from "./server";

const api = treaty(app);
const created = await api.api.users.post({ name: "bob" });
console.log(created.data); // { id: 2, name: "bob" }

const invalid = await api.api.users.post({ name: "" });
console.log(invalid.status, invalid.error?.value);

minLength: 1 に反したリクエストには 422 が返り、どのプロパティが何に反したかが含まれる。

422 {
  type: "validation",
  on: "body",
  property: "/name",
  message: "Expected string length greater or equal to 1",
  expected: { name: " " },
  found: { name: "" },
  ...
}

TypeBox 以外のバリデータ

Standard Schema に対応しているので、t の代わりに Zod、Valibot、Effect Schema、ArkType のスキーマをそのまま body や params に指定できる。1 つのルートの body と query で別のライブラリを混在させることもできる4。JSON Schema を出力できないバリデータについては、TypeScript の型から OpenAPI スキーマを生成する経路が用意されている4。

プラグイン

ルートのまとまりは別の Elysia インスタンスとして定義し、.use() で親に取り込む。親の型にはプラグイン側のルートが合成されるので、App の型を export するのは根の 1 箇所で足りる。ライフサイクルフック(onBeforeHandle など)は既定ではそのインスタンスの中に閉じていて、scoped を指定すると親へ、global を指定すると全体へ適用範囲が広がる5。

const todos = new Elysia({ prefix: "/todos" }).get("/", () => [...]);
const app = new Elysia().use(todos);

Cloudflare Workers での実行

elysia/adapter/cloudflare-worker の CloudflareAdapter を指定する。AoT コンパイルはそのまま使える6。

import { Elysia } from "elysia";
import { CloudflareAdapter } from "elysia/adapter/cloudflare-worker";

export default new Elysia({ adapter: CloudflareAdapter })
  .get("/", () => "Hello Cloudflare Worker!")
  .compile();

Workers には fs がないため、Elysia.file、Static プラグイン、OpenAPI Type Gen は使えない。起動前に Response を生成する .get('/', 'Hello') のような inline の値もエラーになる6。このアダプターは experimental の扱いである6。

出典

  1. Eden - Overview. https://elysiajs.com/eden/overview.html ↩ ↩2

  2. Elysia - At glance. https://elysiajs.com/at-glance.html ↩

  3. Elysia - Validation. https://elysiajs.com/essential/validation.html ↩

  4. Elysia 1.4 - Supersymmetry. https://elysiajs.com/blog/elysia-14 ↩ ↩2

  5. Elysia - Plugin. https://elysiajs.com/essential/plugin.html ↩

  6. Elysia - Integration with Cloudflare Worker. https://elysiajs.com/integrations/cloudflare-worker ↩ ↩2 ↩3