Eden Treaty — официальная клиентская библиотека Elysia, которая генерирует типобезопасный API-клиент непосредственно из типа вашего Elysia-приложения. Поскольку nestelia использует Elysia как HTTP-слой, Eden Treaty работает из коробки.
Установка
bun add @elysiajs/edenПочему маршруты-декораторы не производят типы автоматически
nestelia регистрирует маршруты в runtime через Reflect.getMetadata. TypeScript видит аргументы декораторов (например, путь в @Get('/users')) как string, а не как литеральный тип '/users'. Это означает, что дженерик-параметр экземпляра Elysia никогда не узнаёт о маршрутах через путь декоратора.
Решение — типизированная схема, описывающая ваш API — либо сгенерированная автоматически с помощью CLI nestelia-gen, либо написанная вручную (рядом с контроллером).
Автоматическая генерация — nestelia-gen
nestelia-gen статически анализирует ваши контроллеры с помощью компилятора TypeScript и генерирует полностью типизированный app.schema.ts — включая типы ответов из аннотаций методов. Никакого запуска приложения, никаких побочных эффектов в runtime.
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;Выражения TypeBox для тела/параметров копируются дословно из вашего файла контроллера. Типы ответов берутся из аннотаций методов. Перезапускайте nestelia-gen при каждом добавлении или изменении маршрутов.
// package.json
{
"scripts": {
"gen": "nestelia-gen --tsconfig tsconfig.json src/app.schema.ts",
"build": "bun run gen && tsc"
}
}Автогенерация при старте — опция gen
Вместо запуска nestelia-gen отдельным скриптом передайте gen: true в createElysiaApplication — схема будет перегенерирована автоматически при каждом запуске приложения:
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) выполняет одно приведение типов в runtime — this.httpServer as unknown as TSchema — и возвращает его. Живой Nestelia-сервер (со всеми скомпилированными маршрутами контроллеров) возвращается в runtime; TypeScript видит его как тип схемы с полными дженериками маршрутов. Никаких накладных расходов, никакого дублирования обработки запросов.
Тестирование без listen()
Передайте типизированный сервер напрямую в treaty — метод handle() Elysia обрабатывает запросы внутри процесса:
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[]