Библиотеки валидации Node.js для проектов на TypeScript
Сравните библиотеки валидации Node.js по поддержке TypeScript, формату ошибок, повторному использованию схем и тому, как каждый вариант ведёт себя по мере роста кодовой базы.

Почему валидация запросов со временем расползается
Правила для запросов редко ломаются сразу. Они расползаются понемногу. Сначала в роуте появляется одна проверка на отсутствие email, потом кто-то добавляет ограничение по длине, затем другой человек убирает пробелы, а позже очередная правка начинает отклонять пустые массивы. Через несколько месяцев хендлер выглядит как набор мелких заплаток.
Обычно это происходит прямо внутри роута. Один endpoint проверяет email, другой ещё и переводит его в нижний регистр, а третий молча принимает неверные данные, потому что никто не скопировал последнее правило. Код по-прежнему работает, поэтому дрейф незаметен, пока не начинают поступать обращения в поддержку.
Типичный паттерн выглядит так:
- один хендлер проверяет обязательные поля
- другой преобразует числа через
Number(...) - третий принимает одно и то же поле в двух форматах
- четвёртый возвращает другую ошибку на те же неверные данные
TypeScript не останавливает это на входе. Он помогает уже после того, как данные попали в приложение. Сырые HTTP-данные по-прежнему остаются недоверенным JSON, form data или query string, пришедшими извне. Если запрос говорит age: "twelve", TypeScript-тип это не заблокирует. Код должен распознать это и отклонить на runtime.
Скопированные правила делают проблему ещё хуже. Команды часто дублируют схему, немного меняют одно поле для нового endpoint и идут дальше. Через полгода один и тот же payload клиента существует уже в трёх слегка разных версиях. Один роут принимает phone как необязательное поле, другой требует его, а маршрут импорта по-прежнему использует старую форму. Никто не планировал такой хаос. Он вырастает из скорости и копипаста.
Формат ошибок тоже расползается, и это быстро бьёт по клиентам. Если одна команда возвращает { error: "invalid email" }, другая шлёт { fields: { email: ["bad format"] } }, а третья использует plain text, фронтенд-код становится громоздким. Мобильные приложения, админки и партнёрские интеграции вынуждены обрабатывать каждый endpoint по-особому.
Именно поэтому валидация API-запросов в маленьком кодовой базе обычно кажется нормальной, а в загруженной начинает раздражать. Проблема не только в плохих данных. Проблема в том, что контракт перестаёт быть единым контрактом.
Что сравнивать перед выбором библиотеки
Большинство команд начинают с синтаксиса. Обычно это наименее важная часть. Лучше задать другой вопрос: через шесть месяцев сможет ли команда по-прежнему доверять одной и той же схеме в API, фоновой задаче и форме без дополнительной обвязки везде?
Начните с самого контракта. Некоторые библиотеки позволяют одной схеме одновременно выполнять runtime-проверку и давать TypeScript-типы из того же источника. Это быстро уменьшает дрейф. Если инструмент заставляет определять типы в одном месте, а правила валидации в другом, мелкие расхождения будут проникать в код и оставаться там.
Посмотрите на объект ошибки раньше, чем на developer experience. Ваш API будет отдавать эти ошибки реальным пользователям, фронтенду, логам и инструментам поддержки. Если структура ошибки грязная, странно вложенная или её трудно связать с именами полей, для каждого роута придётся писать дополнительное форматирование.
Быстрый тест помогает:
- Проверьте payload с тремя ошибочными полями
- Добавьте одно отсутствующее поле с default
- Передайте число строкой и посмотрите, что будет
- Используйте одну и ту же схему в HTTP-роуте и worker в очереди
- Превратите сырую ошибку в аккуратный JSON-ответ
Defaults, coercion и transforms заслуживают отдельного внимания. Они экономят время, но могут и скрыть плохие данные. Если библиотека тихо превращает "42" в 42, убирает пробелы или подставляет недостающие значения, команде нужны чёткие правила: когда это полезно, а когда маскирует баг клиента.
Повторное использование схем важнее, чем многие ожидают. В растущем сравнении библиотек валидации Node.js именно здесь многие команды меняют мнение. Схема, которая хорошо работает в Express или Fastify, но неудобна в задачах, CLI-скриптах или React-формах, будет толкать людей к ручному копированию правил.
Потом проверьте, сколько обвязки вам нужно. Если команде приходится писать собственные хелперы для вывода типов, форматирования ошибок, режимов парсинга и общего экспорта схем, библиотека на самом деле не такая уж простая. В крупных кодовых базах эта обвязка превращается во вторую систему валидации, а именно там обслуживание начинает дорожать.
Библиотеки, которые обычно попадают в shortlist
Большинство команд в итоге смотрят на одни и те же шесть инструментов, но они решают разные задачи по-разному. Одни отлично ощущаются в TypeScript с первого дня. Другие лучше переживают рост, когда многим сервисам нужен один и тот же контракт.
Zod обычно оказывается самым простым стартом. Вы пишете одну схему, валидируете на runtime и выводите TypeScript-тип из того же источника. Это держит формы запросов и код приложения ближе друг к другу, поэтому многие команды ставят Zod высоко в списке библиотек валидации Node.js.
Joi существует давно, и это заметно. Его runtime-валидация зрелая, правила широкие, а сообщения об ошибках обычно удобно обрабатывать. Компромисс — работа с типами. TypeScript-типы часто приходится держать рядом со схемой Joi отдельно, поэтому при невнимательности может появиться дрейф.
Yup до сих пор часто встречается, но он лучше подходит для валидации форм, чем для backend-контрактов запросов. Для браузерных форм это может быть нормально. Для API многие команды хотят более строгие схемы, понятнее сужение типов и меньше догадок.
Ajv логичнее, если команда уже использует JSON Schema, делится контрактами между сервисами или генерирует схемы из других инструментов. Он быстрый, широко распространённый и хорошо подходит для валидации по стандартам. Минус в том, что обычный JSON Schema в ежедневной работе с TypeScript ощущается менее естественно.
TypeBox пытается закрыть этот разрыв. Он позволяет определять типизированные объекты JSON Schema, а потом валидировать их через Ajv. Такой набор хорошо работает для команд, которым нужны схемы, удобные для машинной обработки, но при этом не хочется терять безопасность типов в коде.
У io-ts есть своя преданная аудитория, и не зря. Он очень точно соединяет runtime-валидацию и статическую типизацию. Но лучше всего он ощущается там, где команде уже нравятся функциональные подходы. Если разработчикам не близки такие вещи, как combinators и потоки декодирования, внедрение может идти тяжело.
Простое правило помогает:
- Выбирайте Zod, если важнее всего скорость разработки.
- Выбирайте Joi, если глубина runtime-валидации важнее изящества TS.
- Выбирайте Ajv или TypeBox, если JSON Schema уже есть в вашем стеке.
- Выбирайте io-ts, если команда уже пишет в функциональном стиле.
- Выбирайте Yup в основном для форм, а не для backend с большим количеством API.
Лучший выбор — тот, который команда будет понимать и через 18 месяцев, и через 40 endpoint’ов.
Как TypeScript ощущается в повседневной работе
Обычно лучше всего работает схема, которую команда пишет один раз и затем получает из неё и runtime-проверки, и TypeScript-типы. На первый взгляд это мелочь, но она экономит реальное время, когда тело запроса меняется каждую неделю.
Zod лучше многих инструментов подходит под такой сценарий. Вы определяете схему, а потом выводите из неё тип через z.infer. Когда кто-то добавляет новое поле, убирает значение из enum или делает вложенное свойство необязательным, тип меняется вместе со схемой. В код-ревью это легко читать.
Joi устроен иначе. Это зрелый валидатор, но поддержка TypeScript часто ощущается как отдельный слой. Команды обычно держат схему Joi для runtime-проверки и отдельный интерфейс или тип для подсказок в редакторе. Это дублирование сначала незаметно, а потом начинает расходиться. Один хендлер принимает один формат, валидатор допускает другой, и никто не замечает этого, пока запрос не падает в production.
Ajv тоже может подойти, но в основном там, где JSON Schema уже встроен в проект. Если контракты API приходят из OpenAPI или общих схем между сервисами, Ajv имеет смысл. В обычном коде приложения, однако, plain Ajv читается скорее как файл контракта, чем как нативный TypeScript. Union-типы, enum и глубокие вложенные объекты описаны явно, но не всегда приятно просматриваются.
Быстрый способ оценить библиотеки валидации Node.js — посмотреть, как они ведут себя в четырёх обычных задачах:
- добавить одно поле в модель запроса
- прочитать union-тип в pull request
- переиспользовать вложенный объект в трёх endpoint’ах
- обновить общую фабрику, не сломав типы
Именно здесь важны generic helpers. Zod обычно остаётся читаемым, когда вы строите небольшие фабрики схем для pagination, ID или общих API-обёрток. Joi тоже умеет похожие вещи, но типы вокруг таких хелперов часто менее понятны. Ajv тоже хорошо работает с общими фабриками, хотя многие команды комбинируют его с другим инструментом, чтобы упростить вывод типов.
Небольшой пример стартапа показывает компромисс очень наглядно. Если продуктовая команда меняет поля регистрации каждую пятницу, жить легче с одним источником схемы. Здесь Zod обычно выигрывает. Если контракт должен точно совпадать с внешними документами JSON Schema, Ajv лучше переживает рост. Joi всё ещё работает, но только если команда достаточно дисциплинирована, чтобы держать типы и валидаторы в синхроне.
Формат ошибок и ответы API
Валидатор может отклонить неверные данные, но именно формат ошибки часто решает, будет ли это удобно в работе или станет ежедневной головной болью. Важнее не сама библиотека, а то, какую структуру вы отдаёте остальной части приложения.
Zod, Joi и Ajv все дают достаточно деталей, но делают это по-разному. Zod возвращает массив issues с path, message и code вроде invalid_type или too_small. Joi возвращает entries в details с path, человеческим сообщением и type вроде string.email. Ajv обычно отдаёт instancePath, keyword и дополнительные параметры, поэтому он отлично подходит для машинной проверки, даже если сырой текст сообщения кажется менее дружелюбным.
Вложенные массивы быстро показывают разницу. Если items[2].price отсутствует, Zod обычно даёт путь вроде ["items", 2, "price"]. Joi возвращает похожий путь, часто в виде массива. Ajv указывает /items/2/price. Всё это можно использовать, но не хочется, чтобы эта разница просачивалась в ваш API.
Стабильная форма ответа API сильно экономит время позже. Обычно достаточно такого небольшого формата:
field: нормализованный путь вродеitems.2.pricecode: стабильный машинный код вродеrequiredилиinvalid_typemessage: короткое понятное сообщениеsource:body,queryилиparams
Слой сопоставления здесь важнее, чем выбор валидатора, особенно в крупной кодовой базе. Если позже заменить Joi на Zod, фронтенд-формы всё равно должны получать те же field и code. Логи должны по-прежнему группировать ту же ошибку под тем же ярлыком. Служба поддержки должна по-прежнему читать то же сообщение.
Дружелюбные сообщения не всегда должны жить в схеме. Жёсткие и стабильные факты храните в коде валидации: поле отсутствует, тип не тот, значение слишком короткое. Формулировки для пользователя ближе к продуктовой поверхности могут меняться в зависимости от тона, языка или контекста. «Должно быть не менее 8 символов» уместно в коде. Отшлифованная подсказка в signup-форме часто должна находиться во фронтенде или в общем presentation layer.
Если не разделять это, маленькие правки схемы превращаются в breaking changes для API. Формы на фронтенде перестают привязывать ошибки к нужному полю. Логи шумят, потому что одна и та же проблема теперь имеет три варианта сообщения. Стабильный вывод ошибок скучный, и именно поэтому он хорошо стареет.
Что меняется в большой кодовой базе
Схема кажется простой, когда ей владеет один роут. Всё меняется очень быстро, когда один и тот же объект используют двадцать или тридцать endpoint’ов. Одно маленькое изменение может исправить баг в одном API и сломать пять других, которые рассчитывали на старую форму.
Именно здесь многие библиотеки валидации Node.js перестают быть выбором синтаксиса и становятся выбором обслуживания. Вопрос уже не в том, "может ли она проверить этот payload?", а в том, "что произойдёт через шесть месяцев, когда три команды будут трогать один и тот же контракт?"
Повторное использование увеличивает радиус поражения
Общие схемы экономят время, но и распространяют риск. Схема userProfile может начаться в создании аккаунта, а потом появиться в биллинге, админских инструментах, импортах и внутренних задачах. Если одна команда добавляет обязательное поле, теперь всем потребителям приходится учитывать это изменение.
Версионирование тоже усложняется. Старые мобильные приложения и партнёрские интеграции часто продолжают отправлять старые payload’ы ещё долго после того, как backend уже ушёл вперёд. Если v1 отправляет phone как обычную строку, а v2 ожидает структурированный объект, нужна понятная политика: временно поддерживать обе формы, переводить одну в другую или отклонять старые запросы очень простым сообщением об ошибке. Если это правило живёт только в голове одного разработчика, баги быстро накапливаются.
Transforms и coercion дают самые коварные сюрпризы. Превратить "42" в 42 звучит безобидно, пока один сервис это принимает, другой сохраняет строку как есть, а третий сравнивает типы строго. Разбор дат, trimming, lowercasing и значения по умолчанию могут скрывать плохие данные вместо того, чтобы показывать их. Сначала это кажется удобным, а потом начинает путать.
Чтение и тестирование становятся сложнее
Большим командам нужны схемы, которые можно читать без детективного расследования. Глубокие цепочки хелперов, merge, refinement и собственных мапперов ошибок могут превратить простой запрос в головоломку. Новым разработчикам обычно сложнее не со строгими правилами, а с слишком умными правилами.
Тесты помогают, но только если они напрямую покрывают общие валидаторы. Хорошая тестовая схема проверяет принимаемые данные, отклонённые данные, преобразованный результат и версии payload’ов, которые всё ещё отправляют старые клиенты. Небольшой тест хелпера может поймать ошибку контракта до того, как она разойдётся по десяткам endpoint’ов.
Отладка тоже меняется. Когда ошибка приходит из трёх вложенных schema helpers, люди тратят время на поиск места, где живёт правило, вместо того чтобы исправить сам запрос. Понятные имена, небольшие модули схем и очень простой вывод ошибок стареют лучше, чем умные абстракции.
Реальный пример запроса
На примере endpoint для регистрации компромиссы видны быстро. Допустим, сервер ожидает name, email, age и вложенный address с city и zip. В плохом payload ниже две проблемы: age передан строкой, а address.zip отсутствует.
const payload = {
name: "Mia",
email: "[email protected]",
age: "29",
address: { city: "Austin" }
};
Все три библиотеки могут это поймать. Разница в том, сколько кода вы напишете, насколько читаемым он останется и какой объект ошибки получит код валидации API-запроса.
// Zod
import { z } from "zod";
const SignupZod = z.object({
name: z.string().min(1),
email: z.string(),
age: z.number().int().min(18),
address: z.object({
city: z.string(),
zip: z.string().length(5)
})
});
const zodResult = SignupZod.safeParse(payload);
// Joi
import Joi from "joi";
const signupJoi = Joi.object({
name: Joi.string().min(1).required(),
email: Joi.string().required(),
age: Joi.number().integer().min(18).required(),
address: Joi.object({
city: Joi.string().required(),
zip: Joi.string().length(5).required()
}).required()
});
const joiResult = signupJoi.validate(payload, { abortEarly: false });
// Ajv
import Ajv from "ajv";
const ajv = new Ajv();
const signupSchema = {
type: "object",
properties: {
name: { type: "string", minLength: 1 },
email: { type: "string" },
age: { type: "integer", minimum: 18 },
address: {
type: "object",
properties: {
city: { type: "string" },
zip: { type: "string", minLength: 5, maxLength: 5 }
},
required: ["city", "zip"]
}
},
required: ["name", "email", "age", "address"]
};
const validate = ajv.compile(signupSchema);
const valid = validate(payload);
Zod легче всего читать, если команда и так каждый день пишет на TypeScript. Joi тоже читается нормально, но в нём больше вызовов методов и больше шума от .required(). Ajv требует больше настройки, потому что схема более многословная, а валидация происходит в отдельном шаге компиляции.
Для того же плохого payload’а структура ошибок тоже ощущается по-разному.
Zod
- path: ["age"] message: "Expected number, received string"
- path: ["address", "zip"] message: "Required"
Joi
- path: ["age"] message: "\"age\" must be a number"
- path: ["address", "zip"] message: "\"address.zip\" is required"
Ajv
- instancePath: "/age" message: "must be integer"
- instancePath: "/address" message: "must have required property 'zip'"
Через шесть месяцев большинство команд всё ещё быстрее читает версию на Zod, потому что схема выглядит близко к тому TypeScript, который они уже знают. Joi по-прежнему понятен. Ajv хорошо живёт там, где JSON Schema нужен не только в одном Node.js-сервисе, но для одного приложения он требует держать в голове больше правил. Если кому-то придётся чинить этот endpoint в пятницу вечером, Zod обычно понимают первым.
Как выбрать инструмент для своей команды
Начните с того формата ошибок API, который хотите сохранить стабильным. Если клиенты уже ожидают поля вроде code, field и message, сначала проверьте каждый инструмент на соответствие этой форме. Валидатор может отлично выглядеть в демо и всё равно создавать лишнюю работу, если его ошибки приходится каждый раз переделывать перед ответом.
Затем посмотрите на payload’ы, которые ваше приложение обрабатывает чаще всего. Плоские формы просты. Настоящая проверка начинается с вложенных объектов, массивов, частичных обновлений, значений enum, query-параметров, которые приходят строками, и старых полей, которые ещё нужно принимать какое-то время. Именно здесь библиотеки валидации Node.js начинают заметно отличаться.
Не ограничивайтесь игрушечной схемой. Соберите один реальный endpoint с тем типом беспорядка, который уже есть в вашей команде. Payload для заказа с позициями, правилами купонов, необязательными заметками и полем даты лучше, чем маленькая форма логина. Так быстрее станет понятно, подходит ли библиотека под ваши привычки TypeScript или заставляет бороться с типами.
Используйте и одну общую схему. Разместите её там, где её могут использовать и обработка запросов, и внутренний код. Потом внесите одно изменение версии: переименуйте поле, сохраните обратную совместимость и посмотрите, сколько кода придётся тронуть. Библиотеки плохо стареют, когда мелкие изменения расползаются условными операторами по контроллерам, сервисам и тестам.
После этого посчитайте, сколько обвязки вам пришлось написать:
- сопоставление ошибок для API-ответов
- ручные правки типов после парсинга
- собственное приведение типов и transforms
- обёртки схем для повторного использования
- тестовые хелперы только для подготовки валидных данных
Этот счёт говорит больше, чем таблица функций. Если один вариант экономит 20 строк на бумаге, но добавляет мелкие обёртки повсюду, через шесть месяцев он будет раздражать команду.
Выберите инструмент, которым люди будут пользоваться без споров. Обычно это значит: понятные схемы, предсказуемые ошибки и типы, которые остаются честными после рефакторинга. Лучший вариант — тот, который команда быстро читает, безопасно редактирует и не ругает в code review.
Ошибки, которые потом дорого обходятся
Проблемы обычно начинаются с дублирования. Команда пишет TypeScript-интерфейс для редактора, а потом отдельно пишет валидатор для runtime. На первый день оба выглядят нормально. Через три месяца интерфейс говорит, что email необязателен, а валидатор требует его, и никто этого не замечает, пока реальные запросы не начинают падать.
Именно поэтому многие команды разочаровываются в библиотеках валидации Node.js. Проблема не всегда в самой библиотеке. Проблема в разделённом источнике истины. Если ваши типы и runtime-проверки могут расходиться, они это сделают.
Тихое приведение типов вызывает другой тип вреда. Превращать "42" в 42 может казаться полезным. Превращать пустую строку в false или плохую дату во что-то, что "примерно работает", — нет. Для валидации API-запросов неправильные данные обычно должны падать сразу и с понятной ошибкой. Если сервер молча исправляет плохие данные, клиенты продолжат присылать их снова.
Сырые ошибки библиотеки — ещё одна ловушка. Они часто показывают внутренние пути полей, странные формулировки или формы, зависящие от версии. Для логов это нормально. Для публичных API-ответов — плохо. Клиентам нужен стабильный формат, которому можно доверять, даже если позже вы замените Zod на Joi или Ajv.
Более простой формат ответа живёт дольше:
- один верхнеуровневый код ошибки
- короткое понятное сообщение
- список полей с предсказуемыми именами
- без stack trace и внутренних деталей парсера
Бизнес-правила тоже часто оказываются не там, где надо. Схема должна проверять форму, типы и простые ограничения. Она не должна решать, может ли пользователь применить скидку, опубликовать черновик или превысить лимит аккаунта. Такие правила нужно держать в service code, где их проще тестировать и проще менять.
Lock-in на библиотеку подкрадывается незаметно. Обычно он появляется не только из-за синтаксиса схем. Он появляется из-за двадцати общих хелперов, собственных обёрток и адаптеров ошибок по всему кодовому базису. Поздняя миграция превращается в длинную уборку.
Держите один тонкий слой на границе парсинга и сопоставления ошибок. Даже немного дисциплины здесь сильно уменьшает боль, когда кодовая база вырастает.
Быстрые проверки и следующие шаги
Прежде чем окончательно выбрать одну из библиотек валидации Node.js, проверьте один endpoint от начала до конца. Не останавливайтесь на "схема парсится". Отправьте хороший запрос, запрос с отсутствующим полем, с неправильным типом и с лишним полем. Потом посмотрите на финальный API-ответ, который увидит клиент. Если форма ошибки уже сейчас кажется грязной, после пятидесяти endpoint’ов она будет ощущаться ещё хуже.
Используйте один реалистичный вложенный payload и один payload с массивом. Плоская login-форма редко показывает слабые места. Лучше взять что-то вроде заказа с данными клиента, позициями и необязательными правилами скидок. Многие инструменты выглядят нормально на простых объектах, а затем становятся неудобными, когда для массива нужны ошибки на уровне элемента или вложенным полям нужен понятный путь.
Короткий план проверки обычно достаточен:
- Проверьте один request body до финального JSON-ответа с ошибкой.
- Попробуйте один вложенный объект и один массив с двумя сломанными элементами.
- Добавьте старую версию контракта и новую, а потом посмотрите, насколько удобно читать обе.
- Решите, где будут лежать схемы, кто сможет их менять и кто будет проверять эти изменения.
Версионирование лучше решить заранее. Команды часто добавляют его потом, и это обычно приводит к дублированию схем с небольшими отличиями и без понятного владельца. Сейчас выберите простое правило. Можно хранить версии схем в одной папке или держать одну базовую схему и расширять её небольшими изменениями. Оба подхода могут работать. Плохой вариант — позволить каждому члену команды выдумывать свой собственный способ.
Владение тоже важнее, чем многие ожидают. Если backend-код, TypeScript-типы и API-документация меняются в разных местах, дрейф начинается быстро. Одна команда или один понятный reviewer должен утверждать изменения контракта, даже в небольшой кодовой базе.
Если команде нужен второй взгляд до того, как кодовую базу станет сложнее менять, Fractional CTO вроде Oleg Sotnikov может проверить дизайн контракта, настройку TypeScript и план миграции. Такой разбор особенно полезен заранее, когда небольшая правка ещё может сэкономить недели последующей уборки.