PROWEB

API на Bun и Elysia: типобезопасный бэкенд с Drizzle и OpenAPI

Пишем типобезопасный API на Bun и Elysia: одна TypeBox-схема — валидация, типы и OpenAPI; Drizzle для Postgres с миграциями из кода; клиент генерируем из живой спеки. С примерами и практикой продакшена.

Муза Нейронова
15 минут чтения
API на Bun и Elysia: типобезопасный бэкенд с Drizzle и OpenAPI

Хороший фреймворк для API экономит не время на старте, а время на переделке. Как только валидация, TypeScript-типы и документация живут в трёх разных местах, они начинают разъезжаться: поле переименовали в коде — в Swagger оно осталось старым, а в валидации уже новым. Мы в PROWEB собираем бэкенды на связке Bun + Elysia + Drizzle, где схема одна: она валидирует вход, типизирует обработчик и попадает в OpenAPI. В этой статье показываем, как собрать такой API — с кодом и практикой продакшена.

Почему Bun

Bun — рантайм JavaScript и TypeScript на движке JavaScriptCore с набором инструментов вокруг: пакетным менеджером, тест-раннером и поддержкой .env из коробки.

  • Запускает TypeScript напрямую — без ts-node, tsx и отдельных шагов сборки
  • bun install быстро ставит зависимости и пишет лок-файл bun.lock
  • Встроенный bun:test — тесты без настройки Vitest или Jest
  • Совместим с Node: большинство npm-пакетов работают без изменений
bun init -y
bun add elysia @elysiajs/openapi drizzle-orm postgres
bun add -d drizzle-kit @types/bun

bun --hot src/index.ts   # dev-сервер с автоперезагрузкой

Единственная зона осторожности — нативные модули: если библиотека тянет Node-аддон, проверьте её работу под Bun до того, как она окажется в центре архитектуры.

Elysia: HTTP-слой, где валидация и есть типы

Elysia — веб-фреймворк, спроектированный под Bun и end-to-end типобезопасность. Минимальный сервер с живой OpenAPI-документацией выглядит так:

// src/index.ts
import { Elysia } from 'elysia'
import { openapi } from '@elysiajs/openapi'

const app = new Elysia()
  .use(openapi())
  .get('/health', () => ({ status: 'ok' }))
  .listen(3000)

console.log(`Документация: http://localhost:${app.server?.port}/openapi`)

Схемы описываются в стиле TypeBox через t и вешаются на маршрут тремя полями: body (тело запроса), params / query (путь и строка запроса) и response (ответ по кодам статусов). Одна схема решает три задачи сразу:

import { Elysia, t } from 'elysia'

const TaskInput = t.Object({
  title: t.String({ minLength: 1, maxLength: 200 })
})

const Task = t.Object({
  id: t.String({ format: 'uuid' }),
  title: t.String(),
  done: t.Boolean(),
  createdAt: t.String({ format: 'date-time' })
})

new Elysia().post('/tasks', ({ body, set }) => {
  // body уже типизирован: { title: string }
  set.status = 201
  return createTask(body)
}, {
  body: TaskInput,         // валидация запроса
  response: { 201: Task }, // и контроль формы ответа
  detail: { summary: 'Создать задачу', tags: ['tasks'] }
})
  • Неверный запрос Elysia отклоняет сама — с кодом 422 и описанием, без ручных if
  • Внутри обработчика body уже типизирован по схеме — кастовать не нужно
  • response — не перестраховка: он отрезает лишние поля из ответа и попадает в OpenAPI
  • detail — summary, tags и description маршрута идут прямо в документацию

Сквозная логика собирается в плагины. Авторизация — классический пример: derive достаёт токен из заголовка, проверяет и кладёт сессию в контекст; маршруты, подключившие плагин, просто читают session:

// src/plugins/auth.ts
import { Elysia } from 'elysia'

export const auth = new Elysia({ name: 'auth' })
  .derive(({ headers, error }) => {
    const token = headers.authorization?.replace('Bearer ', '')
    const session = token ? verifySession(token) : null

    if (!session) {
      throw error(401, { code: 'UNAUTHORIZED', message: 'Требуется авторизация' })
    }

    return { session }
  })

// src/routes/me.ts
export const meRoutes = new Elysia({ prefix: '/me' })
  .use(auth)
  .get('/', ({ session }) => getProfile(session.userId))

verifySession здесь — ваша проверка (например, @elysiajs/jwt или своя таблица сессий). Throw внутри derive прерывает обработку — запрос даже не дойдёт до маршрута.

Ошибки сводятся в один обработчик: наружу — стабильные коды, в логи — детали.

app.onError(({ code, error, set, request }) => {
  if (code === 'VALIDATION') {
    set.status = 400
    return { code: 'VALIDATION', message: 'Проверьте параметры запроса' }
  }

  console.error(request.url, error)
  set.status = 500
  return { code: 'INTERNAL', message: 'Внутренняя ошибка сервера' }
})

Drizzle: схема базы как код

Drizzle — TypeScript-ORM без магии: таблицы описываются в коде, запросы читаются почти как SQL, миграции генерируются из схемы.

// src/db/schema.ts
import { boolean, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'

export const tasks = pgTable('tasks', {
  id: uuid('id').defaultRandom().primaryKey(),
  title: text('title').notNull(),
  done: boolean('done').notNull().default(false),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
})

Обратите внимание на имена: в TypeScript — camelCase, в SQL — snake_case; имя колонки прописано явно, поэтому в базе не появится createdAt в кавычках.

Подключение — драйвер postgres и обёртка Drizzle. Модуль выполняется один раз, поэтому пул соединений в приложении единственный:

// src/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema'

const client = postgres(process.env.DATABASE_URL!, { max: 10 })
export const db = drizzle(client, { schema })

Миграции — через drizzle-kit: описали схему, сгенерировали SQL, накатили:

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  dialect: 'postgresql',
  schema: './src/db/schema.ts',
  out: './drizzle',
  dbCredentials: { url: process.env.DATABASE_URL! }
})
bunx drizzle-kit generate   # SQL-миграция появится в ./drizzle
bunx drizzle-kit migrate    # применить миграции
  • Файлы миграций храните в репозитории; drizzle-kit push (накат схемы напрямую) оставьте для прототипа
  • В Postgres .returning() отдаёт вставленную или обновлённую строку — второй SELECT не нужен
  • Транзакция — db.transaction(async (tx) => …), и дальше по цепочке передавайте tx, а не db

Запросы — как SQL, но с автодополнением и проверкой типов:

import { desc, eq } from 'drizzle-orm'

const list = await db.select().from(tasks)
  .orderBy(desc(tasks.createdAt))
  .limit(100)

const [created] = await db.insert(tasks)
  .values({ title: 'Собрать API на Bun' })
  .returning()

await db.update(tasks)
  .set({ done: true, updatedAt: new Date() })
  .where(eq(tasks.id, id))

OpenAPI: документация, которая не отстаёт от кода

Схемы TypeBox, которые вы уже описали для валидации, Elysia превращает в OpenAPI сама. Плагину остаётся общая информация о сервисе:

app.use(openapi({
  documentation: {
    info: { title: 'Tasks API', version: '1.0.0' },
    tags: [{ name: 'tasks', description: 'Задачи' }]
  }
}))

Дальше спека работает в двух ролях:

  • Живая документация. Интерактивный UI на /openapi всегда соответствует коду, потому что собирается из него же
  • Контракт для клиентов. JSON-спека на /openapi/json скачивается скриптом, и из неё генерируется типизированный клиент — например, хуки TanStack Query через @hey-api/openapi-ts или типы через openapi-typescript

Если и сервер, и клиент на Elysia, есть более короткий путь — Eden (@elysiajs/eden): типизированный клиент напрямую из типов сервера, без промежуточной спеки. Мы всё же держимся за OpenAPI: это универсальный контракт, по которому работают фронтенд, мобильные клиенты и внешние интеграции, — и он не привязывает потребителей к вашему фреймворку.

Практика: генерируйте клиента из живого сервера (dev или staging), а не из файла, закоммиченного когда-то давно. Цепочка простая: скрипт скачал спеку → перегенерировал клиент → typecheck. Поменяете поле в API — фронтенд не соберётся, пока не обновит клиента.

Сквозной пример: API задач целиком

Соберём всё вместе — CRUD с валидацией, Drizzle и честными кодами ответов:

// src/routes/tasks.ts
import { Elysia, t } from 'elysia'
import { desc, eq } from 'drizzle-orm'
import { db } from '../db'
import { tasks } from '../db/schema'

const Task = t.Object({
  id: t.String({ format: 'uuid' }),
  title: t.String(),
  done: t.Boolean(),
  createdAt: t.String({ format: 'date-time' })
})

const TaskInput = t.Object({ title: t.String({ minLength: 1, maxLength: 200 }) })

const TaskPatch = t.Object({
  title: t.Optional(t.String({ minLength: 1, maxLength: 200 })),
  done: t.Optional(t.Boolean())
})

const ErrorDto = t.Object({ code: t.String(), message: t.String() })

// Date-поля отдаём ISO-строками: и для JSON предсказуемо, и спека честная
const toDto = (row: typeof tasks.$inferSelect) => ({
  id: row.id,
  title: row.title,
  done: row.done,
  createdAt: row.createdAt.toISOString()
})

export const taskRoutes = new Elysia({ prefix: '/tasks', tags: ['tasks'] })
  .get('/', async () => {
    const rows = await db.select().from(tasks)
      .orderBy(desc(tasks.createdAt))
      .limit(100)
    return rows.map(toDto)
  }, {
    response: t.Array(Task),
    detail: { summary: 'Список задач' }
  })

  .get('/:id', async ({ params, error }) => {
    const [row] = await db.select().from(tasks).where(eq(tasks.id, params.id))
    if (!row) return error(404, { code: 'NOT_FOUND', message: 'Задача не найдена' })
    return toDto(row)
  }, {
    params: t.Object({ id: t.String({ format: 'uuid' }) }),
    response: { 200: Task, 404: ErrorDto },
    detail: { summary: 'Получить задачу' }
  })

  .post('/', async ({ body, set }) => {
    const [row] = await db.insert(tasks)
      .values({ title: body.title })
      .returning()

    set.status = 201
    return toDto(row)
  }, {
    body: TaskInput,
    response: { 201: Task },
    detail: { summary: 'Создать задачу' }
  })

  .patch('/:id', async ({ params, body, error }) => {
    const patch: Partial<typeof tasks.$inferInsert> = { updatedAt: new Date() }
    if (body.title !== undefined) patch.title = body.title
    if (body.done !== undefined) patch.done = body.done

    const [row] = await db.update(tasks)
      .set(patch)
      .where(eq(tasks.id, params.id))
      .returning()

    if (!row) return error(404, { code: 'NOT_FOUND', message: 'Задача не найдена' })
    return toDto(row)
  }, {
    params: t.Object({ id: t.String({ format: 'uuid' }) }),
    body: TaskPatch,
    response: { 200: Task, 404: ErrorDto },
    detail: { summary: 'Обновить задачу' }
  })

  .delete('/:id', async ({ params, set }) => {
    await db.delete(tasks).where(eq(tasks.id, params.id))
    set.status = 204
  }, {
    params: t.Object({ id: t.String({ format: 'uuid' }) }),
    detail: { summary: 'Удалить задачу' }
  })
// src/index.ts
import { Elysia } from 'elysia'
import { openapi } from '@elysiajs/openapi'
import { taskRoutes } from './routes/tasks'

const app = new Elysia()
  .use(openapi({
    documentation: {
      info: { title: 'Tasks API', version: '1.0.0' },
      tags: [{ name: 'tasks', description: 'Задачи' }]
    }
  }))
  .onError(({ code, error, set, request }) => {
    if (code === 'VALIDATION') {
      set.status = 400
      return { code: 'VALIDATION', message: 'Проверьте параметры запроса' }
    }

    console.error(request.url, error)
    set.status = 500
    return { code: 'INTERNAL', message: 'Внутренняя ошибка сервера' }
  })
  .use(taskRoutes)
  .listen(3000)

export { app }

Мелочь, которая показывает ценность схем: :id описан как uuid — кривой идентификатор отсеется валидацией с 422 ещё до запроса к Postgres, а не упадёт 500 внутри драйвера.

Практика эксплуатации

Тесты без сети. Elysia-приложение можно дёргать обычным Request через app.handle — без порта и сокетов:

// test/tasks.test.ts
import { describe, expect, it } from 'bun:test'
import { app } from '../src/index'

describe('POST /tasks', () => {
  it('создаёт задачу', async () => {
    const res = await app.handle(new Request('http://localhost/tasks', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ title: 'Первая задача' })
    }))

    expect(res.status).toBe(201)
    expect((await res.json()).title).toBe('Первая задача')
  })

  it('отклоняет пустой заголовок', async () => {
    const res = await app.handle(new Request('http://localhost/tasks', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ title: '' })
    }))

    expect(res.status).toBe(422)
  })
})
  • Для интеграционных тестов — отдельная база или Testcontainers; обработчики тестируются и без неё
  • Docker: базовый образ oven/bun и bun install --frozen-lockfile; если нужен один артефакт — bun build --compile собирает самодостаточный бинарник
  • Наблюдаемость: pino для структурных логов, requestId в каждом ответе и /health для балансировщика
  • Гигиена: лимит размера body, rate limit на публичных эндпоинтах, CORS через @elysiajs/cors, если фронтенд на другом домене
  • Миграции — отдельный шаг деплоя до старта приложения, а не «при первом запросе»

История из практики

Сервер одного из наших продуктов написан ровно на этом стеке. Он отдаёт живую спеку, скрипт в монорепозитории скачивает её и генерирует типизированный клиент (TanStack Query) сразу для трёх фронтендов — сайта, кабинета и админки. Когда мы меняем API — переименовываем поле или уточняем тип — сборка фронтендов падает на typecheck ещё до деплоя: клиент уже знает новое поле, а старый код с ним несовместим. Документация в этой схеме не ведётся отдельно — она просто не может отстать.

Когда выбрать что-то другое

  • NestJS — большая команда и нужна архитектура с DI, модулями и гвардами «из коробки»
  • Fastify — критична экосистема плагинов; он тоже запускается под Bun, если рантайм менять хочется, а фреймворк — нет
  • Hono — целитесь в edge-среды и один код на нескольких рантаймах

В остальных случаях связка Bun + Elysia + Drizzle даёт необычно короткий путь от «описал схему» до «валидация работает, типы сошлись, документация готова, клиент сгенерирован».

Коротко

Одна TypeBox-схема в Elysia даёт валидацию, TypeScript-типы и OpenAPI; Drizzle держит схему базы в коде и генерирует миграции; типизированный клиент собирается из живой спеки сервера. Код и документация перестают расходиться — потому что это один и тот же код.

Поделиться

Доверьте профессионалам разработку вашего проекта