Почти три года назад я начал проект, который должен был принести структурированный бэкенд в стиле 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 — и, неожиданно, история о том, как мой ишью закрыл архитектор языка.
Требования
Мне нужен был способ определить валидируемую процедуру так, чтобы:
Типы из Zod-схем (
params,body,query,output) автоматически проникали в хендлер.Те же типы прокидывались в сгенерированный клиент.
Те же типы были доступны в сервисном слое через утилитарные типы вроде
VovkParams<typeof Controller.method>.Работало с декораторами 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 вышел. Если вам интересно попробовать или просто посмотреть на архитектуру — буду рад обратной связи.
📖 Документация: vovk.dev
🐙 GitHub: github.com/finom/vovk
📝 Англоязычный анонс: vovk.dev/blog/announcement
