Skip to content

Eden Treaty は Elysia 公式のクライアントライブラリで、Elysia アプリケーションの型から型安全な API クライアントを直接生成します。nestelia は HTTP レイヤーに Elysia を使用しているため、Eden Treaty はそのまま動作します。

セットアップ

bash
bun add @elysiajs/eden

デコレーターのルートが自動的に型を生成しない理由

nestelia は Reflect.getMetadata を通じてランタイムでルートを登録します。TypeScript はデコレーターの引数(例:@Get('/users') のパス)を、リテラル型 '/users' ではなく string として扱います。そのため、Elysia インスタンスのジェネリックパラメーターは、デコレーターのパスだけではルート情報を得られません。

解決策は、API の表面を記述する型付きスキーマです — nestelia-gen CLI によって自動生成するか、コントローラーと並べて手動で書くかのいずれかです。

自動生成 — nestelia-gen

nestelia-gen は TypeScript コンパイラーを使用してコントローラーを静的解析し、メソッドのアノテーションからレスポンス型も含めた完全に型付けされた app.schema.ts を生成します。アプリの起動もランタイムの副作用も不要です。

bash
bunx nestelia-gen --tsconfig tsconfig.json src/app.schema.ts

ジェネレーターが型を取得できるよう、コントローラーのメソッドに戻り値の型アノテーションを追加してください:

typescript
@Controller("/users")
export class UsersController {
  @Get("/")
  getAll(): User[] { … }

  @Get("/:id")
  getOne(@Param(IdParams) p: Static<typeof IdParams>): User | null { … }

  @Post("/")
  create(@Body(CreateDto) body: Static<typeof CreateDto>): User { … }
}

生成される app.schema.ts は以下のようになります:

typescript
// auto-generated by nestelia-gen — do not edit manually
import { Elysia, t } from "elysia";
import type { User } from "./users/user.entity";

export const appSchema = new Elysia()
  .get("/users", (): User[] => undefined as never)
  .get("/users/:id", (): User | null => undefined as never, {
    params: t.Object({ id: t.String() })
  })
  .post("/users", (): User => undefined as never, {
    body: t.Object({ name: t.String() })
  });

export type App = typeof appSchema;

body/params の TypeBox 式はコントローラーファイルからそのままコピーされます。レスポンス型はメソッドのアノテーションから取得されます。ルートを追加・変更するたびに nestelia-gen を再実行してください。

json
// package.json
{
  "scripts": {
    "gen": "nestelia-gen --tsconfig tsconfig.json src/app.schema.ts",
    "build": "bun run gen && tsc"
  }
}

起動時の自動生成 — gen オプション

nestelia-gen を別のスクリプトとして実行する代わりに、createElysiaApplicationgen: true を渡すと、アプリの起動時に毎回スキーマが自動的に再生成されます:

typescript
const app = await createElysiaApplication(AppModule, { gen: true });

出力パスや tsconfig をカスタマイズするにはオブジェクトを渡します:

typescript
const app = await createElysiaApplication(AppModule, {
  gen: { output: "src/schema.ts", tsconfig: "tsconfig.app.json" },
});

これはブートストラップ前に bunx nestelia-gen [args] を実行することと同等です。開発中に CLI を手動で再実行する必要がなくなります。

その後、withSchema()treaty でスキーマを使用します:

typescript
// src/main.ts
import { createElysiaApplication } from "nestelia";
import { AppModule } from "./app.module";
import { appSchema } from "./app.schema"; // ← auto-generated

const app = await createElysiaApplication(AppModule);
const typedServer = app.withSchema(appSchema);
export type App = typeof typedServer;

await typedServer.listen(3000);
typescript
// src/client.ts
import { treaty } from "@elysiajs/eden";
import type { App } from "./main";

const client = treaty<App>("http://localhost:3000");

const { data } = await client.users.get();              // User[]
const { data: user } = await client.users.post({ name: "Alice" }); // User
const { data: found } = await client.users({ id: "1" }).get();     // User | null

手動スキーマ — コントローラーと並べて配置

ビルドステップを必要としない明示的なスキーマを好むチームのために、各コントローラーの隣に手書きの Elysia スキーマをエクスポートします。スキーマはクライアント型の唯一の情報源であり、コントローラーはビジネスロジックの唯一の情報源です。

typescript
// src/users/users.controller.ts
import { Elysia, t, type Static } from "elysia";
import { Controller, Get, Post, Delete, Body, Param } from "nestelia";
import type { User } from "./user.entity";
import { UsersService } from "./users.service";

const IdParams = t.Object({ id: t.String() });
const CreateDto = t.Object({ name: t.String() });

export const usersSchema = new Elysia({ prefix: "/users" })
  .get("/", (): User[] => [])
  .post("/", (): User => ({} as User), { body: CreateDto })
  .get("/:id", (): User | null => null, { params: IdParams })
  .delete("/:id", (): { success: boolean } => ({ success: true }), { params: IdParams });

@Controller("/users")
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Get("/")    getAll(): User[] { return this.users.findAll(); }
  @Post("/")   create(@Body(CreateDto) body: Static<typeof CreateDto>): User { return this.users.create(body); }
  @Get("/:id") getOne(@Param(IdParams) p: Static<typeof IdParams>): User | null { return this.users.findOne(p.id); }
  @Delete("/:id") remove(@Param(IdParams) p: Static<typeof IdParams>): { success: boolean } { return this.users.remove(p.id); }
}

すべてのモジュールのスキーマを main.ts で組み合わせます:

typescript
// src/main.ts
import { createElysiaApplication } from "nestelia";
import { Elysia } from "elysia";
import { AppModule } from "./app.module";
import { usersSchema } from "./users/users.controller";
import { postsSchema } from "./posts/posts.controller";

const app = await createElysiaApplication(AppModule);
const typedServer = app.withSchema(new Elysia().use(usersSchema).use(postsSchema));
export type App = typeof typedServer;

await typedServer.listen(3000);

withSchema() の仕組み

app.withSchema(schema) はランタイムで一度だけキャスト — this.httpServer as unknown as TSchema — を行い、それを返します。実際の Nestelia サーバー(すべてのコントローラールートがコンパイルされた状態)がランタイムで返され、TypeScript はそれを完全なルートジェネリクスを持つスキーマ型として認識します。オーバーヘッドゼロ、リクエスト処理の重複なし。

listen() を使わないテスト

型付きサーバーを直接 treaty に渡します — Elysia の handle() メソッドがプロセス内でリクエストを処理します:

typescript
import { treaty } from "@elysiajs/eden";
import { createElysiaApplication } from "nestelia";
import { AppModule } from "./app.module";
import { appSchema } from "./app.schema";

const app = await createElysiaApplication(AppModule);
const client = treaty<App>(app.withSchema(appSchema)); // listen() 不要

const { data } = await client.users.get(); // User[]

MIT ライセンスの下で公開されています。