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。
出典
-
Eden - Overview. https://elysiajs.com/eden/overview.html ↩ ↩2
-
Elysia - At glance. https://elysiajs.com/at-glance.html ↩
-
Elysia - Validation. https://elysiajs.com/essential/validation.html ↩
-
Elysia 1.4 - Supersymmetry. https://elysiajs.com/blog/elysia-14 ↩ ↩2
-
Elysia - Plugin. https://elysiajs.com/essential/plugin.html ↩
-
Elysia - Integration with Cloudflare Worker. https://elysiajs.com/integrations/cloudflare-worker ↩ ↩2 ↩3