Eden Treaty는 Elysia 공식 클라이언트 라이브러리로, Elysia 애플리케이션 타입에서 직접 타입 안전 API 클라이언트를 생성합니다. nestelia는 HTTP 레이어로 Elysia를 사용하므로 Eden Treaty는 즉시 작동합니다.
설치
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를 생성합니다. 앱 부트스트랩이나 런타임 부작용이 없습니다.
bunx nestelia-gen --tsconfig tsconfig.json src/app.schema.ts생성기가 타입을 가져올 수 있도록 컨트롤러 메서드에 반환 타입 어노테이션을 추가하세요:
@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는 다음과 같습니다:
// 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을 다시 실행하세요.
// package.json
{
"scripts": {
"gen": "nestelia-gen --tsconfig tsconfig.json src/app.schema.ts",
"build": "bun run gen && tsc"
}
}시작 시 자동 생성 — gen 옵션
nestelia-gen을 별도 스크립트로 실행하는 대신, createElysiaApplication에 gen: true를 전달하면 앱이 시작될 때마다 스키마가 자동으로 재생성됩니다:
const app = await createElysiaApplication(AppModule, { gen: true });출력 경로나 tsconfig를 커스터마이즈하려면 객체를 전달하세요:
const app = await createElysiaApplication(AppModule, {
gen: { output: "src/schema.ts", tsconfig: "tsconfig.app.json" },
});이는 부트스트랩 전에 bunx nestelia-gen [args]를 실행하는 것과 동일합니다 — 라우트를 변경할 때마다 CLI를 수동으로 재실행하지 않아도 되어 개발 시 편리합니다.
그런 다음 withSchema()와 treaty로 스키마를 사용하세요:
// 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);// 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 스키마를 내보내세요. 스키마는 클라이언트 타입의 단일 진실 공급원이고, 컨트롤러는 비즈니스 로직의 단일 진실 공급원입니다.
// 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에서 모든 모듈의 스키마를 조합하세요:
// 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() 메서드가 프로세스 내에서 요청을 처리합니다:
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[]