Почти три года назад я начал проект, который должен был принести структурированный бэкенд в стиле NestJS в Next.js App Router. Сегодня я выпускаю Vovk.ts — мета-фреймворк, который превращает Route Handlers в полноценный API-слой с контроллерами, сервисами и процедурами, автоматически генерируя типизированные RPC-клиенты, OpenAPI-спецификации и определения инструментов для LLM.

Но эта статья не столько о фичах (хотя без них не обойтись), сколько о технических задачах, которые пришлось решать по дороге, — и о граблях, на которые я наступил. Если вы когда-либо проектировали библиотеку с нетривиальной системой типов в TypeScript, думаю, некоторые из этих историй вам будут знакомы.

Что получилось: обзор в коде

Прежде чем перейти к урокам, покажу, что делает Vovk.ts, — иначе дальнейшие рассуждения повиснут в воздухе.

Контроллер определяется как класс со статическими методами и декораторами:

@prefix("users")
export default class UserController {
  @get('{id}')
  static getUser = procedure({
    params: z.object({ id: z.string().uuid() }),
    output: z.object({ id: z.string(), name: z.string() }),
  }).handle(async (req, { id }) => {
    return UserService.getUser(id);
  });
}

CLI генерирует типизированный клиент, который зеркалит контроллер:

import { UserRPC } from 'vovk-client';

const user = await UserRPC.getUser({ params: { id: '123' } });

Тот же эндпоинт вызывается локально для SSR без HTTP:

await UserController.getUser.fn({ params: { id: '123' } });

И превращается в инструмент для LLM-агента одним вызовом:

const { tools } = deriveTools({ modules: { UserRPC, TaskController } });

Из одного определения генерируются: типизированный клиент, OpenAPI 3.1-спецификация, локальный вызов для SSR, инструменты для AI-агентов и MCP-серверов. Плюс каждый route.ts компилируется в отдельную serverless-функцию с независимой конфигурацией рантайма.

Теперь к тому, что было непросто.

Урок первый: я не знал TypeScript

Когда я начинал этот проект, за плечами было больше 20 лет написания кода и несколько лет плотной работы с TypeScript. Я считал, что знаю язык хорошо. Оказалось — нет.

Разработка фреймворка затаскивает в те углы системы типов, которых прикладной код не касается. Conditional types, рекурсивные типы, inference через generic-границы, шаблонные литеральные типы, распределённые условные типы — всё это стало ежедневной рутиной. Каждый раз, когда я решал одну задачу вывода типов, за ней обнаруживалась следующая.

Простой пример. Допустим, вы хотите, чтобы тип ответа хендлера автоматически становился типом, который возвращает сгенерированный клиент. Звучит тривиально — пока вы не учтёте, что хендлер может вернуть NextResponse, или обёрнутый JSON, или generator для стриминга, и что тип должен прокинуться через валидацию, через output-схему, через кодогенерацию — и оказаться на клиенте ровно таким, каким его ожидает потребитель.

Три года спустя я продолжаю регулярно обнаруживать, что какое-то моё предположение о TypeScript было неполным или ошибочным. Это не скромность напоказ — это буквальный опыт.

Урок второй: история с тремя библиотеками валидации

Изначально Vovk.ts поддерживал три библиотеки валидации: Zod, Yup и class-validator. Для каждой существовал отдельный пакет-адаптер: vovk-zod, vovk-yup, vovk-dto. Задача адаптера — конвертировать схемы валидации в JSON Schema, потому что JSON Schema нужна для OpenAPI-спецификации, для определений AI-инструментов и для валидации на клиенте.

С Zod всё было терпимо — конвертер zod-to-json-schema работал полноценно. С Yup начались проблемы: конвертер содержал массу багов. А с class-validator/class-transformer ситуация была ещё интереснее: пакет class-validator-jsonschema в целом работал, но имел известные проблемы с массивами и требовал reflect-metadata. Три адаптера — три набора граничных случаев, три секции в документации с описанием «известных ограничений» и обходных путей.

Я уже привык к этому состоянию, описал все баги в документации, написал воркэраунды в самой библиотеке — и тут узнал о Standard Schema. Мой первый вопрос: включает ли стандарт конвертацию в JSON Schema? Не включал. Но ишью на GitHub, в котором об этом просили, набрал столько реакций, что в итоге появился Standard JSON Schema — и его адаптировали Zod, ArkType и Valibot.

Решение стало очевидным: убить все три пакета-адаптера и поддерживать только библиотеки, реализующие Standard Schema + Standard JSON Schema. Цепочка zod → vovk-zod → procedure превратилась в zod → procedure. Документация сократилась. Настройка проекта упростилась. Функция procedure стала универсальной и перестала зависеть от конкретной библиотеки валидации.

Это было болезненное решение — выбросить код, который работал и был отлажен. Но альтернативой было бесконечно тащить за собой адаптеры с их багами и ограничениями. Экосистема решила проблему лучше, чем я мог бы решить её в одиночку.

Урок третий: главное — не что добавить, а что убрать

Все обсуждают, что фреймворк умеет. Никто не обсуждает, что из него вырезали. За три года я удалил из Vovk.ts больше кода, чем в нём сейчас содержится.

Поскольку Vovk.ts изначально вдохновлялся NestJS, я тащил за собой NestJS-специфичные вещи. CLI-команда generate имела флаги, заточенные под NestJS-паттерны. Клиент на стороне браузера принимал не только сырые данные, но и DTO-инстансы — можно было передать объект класса напрямую. Каждая такая фича требовала отдельной секции в документации, отдельных тестов, отдельных граничных случаев.

Но самое показательное удаление — Worker Procedure Call, или WPC. По аналогии с RPC, я спроектировал систему, которая использовала такой же дизайн со статическими классами и генерировала модули для вызова Web Worker в отдельном потоке:

await MyWPC.heavyWork(...args);

Type-safe вызов Web Worker с автогенерацией клиента. Это выглядело красиво. Это работало. И это было совершенно не нужно бэкенд-фреймворку.

Сейчас это очевидно. Тогда, в режиме эксперимента, это казалось логичным расширением: раз уже есть кодогенерация и type-safe вызовы, почему бы не применить тот же паттерн к воркерам? Ответ: потому что это размывает фокус. Фреймворк пытается быть всем для всех и в итоге не делает хорошо ничего.

Переломный момент наступил, когда я удалил WPC, NestJS-специфичные флаги и DTO-инстансы за один рефакторинг. API стал меньше и однороднее. Документация — проще. Каждая оставшаяся фича получила больше внимания. Я начал оценивать новые идеи вопросом «что мне убрать?» вместо «куда это можно воткнуть?».

Урок четвёртый: задача про синтаксис процедуры

Этот урок заслуживает отдельного разбора, потому что это задача на стыке дизайна API, ограничений системы типов TypeScript — и, неожиданно, история о том, как мой ишью закрыл архитектор языка.

Требования

Мне нужен был способ определить валидируемую процедуру так, чтобы:

  1. Типы из Zod-схем (params, body, query, output) автоматически проникали в хендлер.

  2. Те же типы прокидывались в сгенерированный клиент.

  3. Те же типы были доступны в сервисном слое через утилитарные типы вроде VovkParams<typeof Controller.method>.

  4. Работало с декораторами HTTP-методов (@get, @post и т.д.).

Звучит несложно. На практике каждый очевидный синтаксис ломался.

Подход 1: декоратор целиком

Первая идея — сделать всё через декоратор:

// ❌ Не работает
@get('{id}')
@withZod({ params: z.object({ id: z.string() }) })
static async getUser(req: NextRequest, { id }: { id: string }) { ... }

Проблема: декораторы в TypeScript не меняют тип того, что декорируют. Декоратор может дополнить поведение в рантайме, но система типов не видит, что getUser теперь принимает валидированные параметры. Соответственно, VovkParams<typeof Controller.getUser> не знает ни о каких схемах.

Подход 2: handle как опция

Вторая идея — передать хендлер как свойство объекта в procedure:

export class UserService {
  static async getUser(id: VovkParams<typeof UserController.getUser>['id']) { ... }
}
// ❌ Не работает для кросс-инференса
static getUser = procedure({
  params: z.object({ id: z.string() }),
  handle: (req, { id }) => { 
    return UserService.getUser(id);
  }
});

Моя цель была реализовать вывод типов в сервисном классе, когда сервис возвращает результат, используемый в процедуре контроллера. При таком синтаксисе TypeScript выбрасывал implicit any, убивая типы и в сервисном методе, и в процедуре.

Я описал проблему в ишью microsoft/TypeScript#58616, без особой надежды, просто чтобы зафиксировать. К моему удивлению, ишью получило внимание. А потом произошло то, чего я совсем не ожидал: его закрыл Андерс Хейлсберг — создатель C# и архитектор TypeScript. Честно скажу, на тот момент я даже не знал это имя (да, стыдно). Когда я выяснил, кто именно починил мою проблему, я воспринял это как знак: проект не должен остаться очередным заброшенным репозиторием на моём GitHub.

Но даже после этого фикса оставалась другая проблема. Когда handle передаётся как свойство объекта в procedure(), TypeScript не мог принудительно ограничить возвращаемый тип хендлера. handle мог вернуть что угодно, и компилятор это молча проглатывал. Мне пришлось добавить в документацию большое предупреждение: возвращаемый тип нужно указывать вручную:

return result satisfies VovkOutput<typeof MyController.myMethod>;

Для RPC-фреймворка, где типизация — ключевое обещание, заставлять пользователя вручную аннотировать типы — это провал. Документация с этим предупреждением убивала всю мотивацию. Я понимал, что проект в таком виде не может конкурировать с тем же tRPC, где вывод типов работает прозрачно. Началась очередная фаза, когда я не мог двигаться дальше.

Как появился синтаксис procedure(def).handle(fn)

Я экспериментировал долго и безрезультатно. В какой-то момент, будучи готовым принять любую идею, я попросил GitHub Copilot предложить альтернативный синтаксис. И он выдал цепочку procedure(...).handle(fn).

Я не верил, что это сработает. Но тесты прошли. Вывод типов заработал. Последнее препятствие было сломано.

@get('{id}')
static getUser = procedure({
  params: z.object({ id: z.string().uuid() }),
}).handle((req, { id }) => {
  return UserService.getUser(id);
});

Почему это работает, а handle как опция — нет? Потому что procedure(definition) возвращает объект с методом .handle(), и TypeScript резолвит дженерики в два шага: сначала фиксирует типы из definition, затем использует их для вывода типов в .handle(). Когда всё в одном объекте, компилятор пытается вывести всё одновременно — и ломается.

Результат: procedure(definition) фиксирует схемы валидации и их типы. .handle(fn) получает эти типы как дженерик и проверяет возвращаемый тип хендлера. Декоратор @get('{id}') остаётся сверху и регистрирует роут в рантайме, не трогая тип. Снаружи VovkParams, VovkBody и остальные утилиты извлекают типы из статического свойства класса без ручных аннотаций.

Выглядит непривычно — chained call на статическом свойстве класса с декоратором сверху. Но это единственный синтаксис, который удовлетворяет всем требованиям. Я долго пытался найти что-то более конвенциональное. В итоге смирился: правильный ответ не всегда выглядит привычно. А тот факт, что последний кусок пазла подсказал Copilot — ирония, к которой я отношусь с юмором.

Performance: цена абстракции

Когда строишь фреймворк поверх другого фреймворка, главный страх — превратить быстрый Next.js в неповоротливого монстра. Поэтому я уделил много времени замерам оверхеда.

После некоторых оптимизаций результаты оказались воодушевляющими:

Роутинг: Мы получили O(1) по времени. Будь у вас 10 или 10 000 контроллеров, поиск нужного хендлера занимает примерно 1.3 мкс (на Apple M4 Pro).

Throughput: Система выдает около 800 000 оп/сек на ядро.

Cold Start: Здесь цена выше — O(n), так как декораторы инициализируются при запуске. Для 1000 контроллеров это ~5.7 мс.
Чтобы нивелировать влияние холодного старта в serverless-среде, я ввел понятие Segments. Вы можете разбивать API на независимые сегменты (мини-бэкенды), которые компилируются в отдельные serverless-функции. Это позволяет держать бандлы маленькими, а запуск — мгновенным.

Подробные цифры, графики и методологию тестирования я вынес на отдельную страницу: 📖 Next.js API Route Performance Overhead

Вместо заключения

Эти уроки не уникальны для разработки фреймворков. Но когда строишь что-то в одиночку, годами, с движущейся платформой под ногами — всё сжимается. Нельзя отложить решение на команду. Нельзя ждать консенсуса. Принимаешь решение, живёшь с ним, иногда откатываешь через год.

Vovk.ts вышел. Если вам интересно попробовать или просто посмотреть на архитектуру — буду рад обратной связи.