Валидация схем в TypeScript: Zod, Valibot и границы приложения
TypeScript проверяет типы на этапе компиляции, но JSON из сети приходит в рантайме и типам не подчиняется. Разбираем, чем и как валидировать данные на входе, во что это обходится по размеру бандла и производительности.

Вы описали интерфейс User, повесили его на ответ fetch через as User — и в проде получили Cannot read properties of undefined, потому что бэкенд прислал null вместо строки. TypeScript тут бессилен: аннотации типов стираются при компиляции, а as — это обещание разработчика, а не проверка. Всё, что приходит извне (тело запроса, ответ API, переменные окружения, содержимое localStorage), в рантайме — это unknown, и превращать его в типизированные данные должна валидация, а не приведение типов.
Разберём, чем валидировать данные на границе приложения: как связать схему с выводимым типом, чтобы не дублировать описание, чем Zod отличается от Valibot по размеру бандла, и когда ручная проверка через type guard всё ещё уместнее библиотеки.
Почему as и type guard вручную — это разные уровни доверия
Есть три способа сказать компилятору, что unknown на самом деле User. Они не равнозначны.
Приведение через as ничего не проверяет. Это просто указание компилятору замолчать. Ошибка проявится позже и не там, где произошла — при обращении к отсутствующему полю.
Type guard вручную — функция, которая возвращает value is User и физически проверяет поля. Работает, но при изменении интерфейса легко забыть обновить проверку: тип и guard живут в разных местах и компилятор не заставит их совпасть.
Главная проблема ручного type guard — не в том, что он многословен, а в том, что тип и его проверка расходятся молча. Схема-первый подход решает именно это: тип выводится из схемы, разъехаться нечему.
Схема-валидатор (Zod, Valibot и другие) описывает данные один раз, а статический тип выводится из схемы автоматически. Проверка и тип не могут разойтись, потому что это один источник.
Zod: тип выводится из схемы
Пример на Zod 3.x. Схема описывается один раз, тип получаем через z.infer:
import { z } from "zod";
// Описываем схему один раз
const UserSchema = z.object({
id: z.number().int().positive(),
email: z.string().email(),
name: z.string().min(1),
// необязательное поле с дефолтом
role: z.enum(["admin", "user"]).default("user"),
});
// Тип выводится из схемы, дублировать интерфейс не нужно
type User = z.infer<typeof UserSchema>;
async function loadUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
const raw: unknown = await res.json();
// parse бросает ZodError, если данные не подходят под схему
return UserSchema.parse(raw);
}
Ключевое отличие от as: parse действительно ходит по объекту и проверяет каждое поле. Если бэкенд прислал email: null, вы получите исключение с понятным путём до сломанного поля, а не падение через три экрана.
safeParse вместо исключений
На границе HTTP-запроса исключение — не лучший способ обработки: вам нужен ответ с кодом 400 и телом ошибки, а не стек. Для этого есть safeParse, который возвращает результат вместо броска:
const result = UserSchema.safeParse(raw);
if (!result.success) {
// result.error.issues — массив с путём и сообщением по каждой ошибке
return { status: 400, body: result.error.issues };
}
// result.data типизирован как User
const user = result.data;
Zod против Valibot: цена вопроса — это байты в бандле
Zod — де-факто стандарт, но у него есть цена: библиотека тянется в бандл целиком, потому что API построен на цепочках методов (z.string().email().min(1)), которые плохо поддаются tree-shaking. Для серверного кода это неважно, для фронтенда — иногда критично.
Valibot решает ту же задачу, но через отдельные функции вместо методов, что позволяет сборщику выкинуть неиспользуемое. Та же схема выглядит иначе:
import * as v from "valibot";
const UserSchema = v.object({
id: v.pipe(v.number(), v.integer(), v.minValue(1)),
email: v.pipe(v.string(), v.email()),
name: v.pipe(v.string(), v.minLength(1)),
});
type User = v.InferOutput<typeof UserSchema>;
const result = v.safeParse(UserSchema, raw);
if (!result.success) {
// result.issues
}
Точные размеры бандла зависят от версии, набора использованных валидаторов и настроек сборщика — сверяйте на bundlephobia для конкретных версий, которые вы ставите, а не по цифрам из статей. Общее правило: Valibot выигрывает тем сильнее, чем меньше валидаторов вы реально используете, потому что импортируется только нужное.
| Критерий | Zod | Valibot | Ручной type guard |
|---|---|---|---|
| Тип выводится из схемы | Да (z.infer) | Да (v.InferOutput) | Нет, пишется отдельно |
| Tree-shaking | Слабый | Сильный | Не нужен |
| API | Цепочки методов | Функции + pipe | Обычный код |
| Зависимость в бандле | Есть | Есть, меньше | Нет |
| Экосистема, интеграции | Широкая | Растёт | — |
Когда что выбирать
- Серверный код (Node, Deno) — берите Zod. Размер бандла на сервере не важен, а экосистема интеграций (валидация в роутерах, генерация из схем) у Zod шире.
- Фронтенд, где важен вес — смотрите на Valibot, особенно если валидаторов немного. Разница проявляется на схемах, где вы используете десяток типов проверок из сотни доступных.
- Одна-две простые проверки в изолированном месте — ручной type guard без библиотеки. Тащить зависимость ради проверки, что пришёл объект с двумя строковыми полями, — перебор.
- Уже есть Zod в проекте — не мигрируйте ради экономии килобайтов. Смешивать два валидатора хуже, чем жить с одним неоптимальным.
Три ошибки, которые дороже всего обходятся
Валидировать не на границе, а в середине
Схему нужно применять там, где unknown впервые входит в приложение: сразу после res.json(), при чтении переменных окружения, при разборе тела запроса. Если пропустить проверку на входе и валидировать где-то глубже в бизнес-логике, невалидные данные успеют разъехаться по коду, и найти источник станет сложнее.
Путать parse и safeParse
parse бросает исключение — это уместно там, где сломанные данные означают баг, который должен упасть шумно (например, ответ вашего же API, который обязан быть валидным). safeParse возвращает результат — это для данных от пользователя или третьей стороны, где ошибка ожидаема и требует ответа, а не падения.
Забывать про трансформацию и дефолты в выводимом типе
В Zod тип на входе схемы и на выходе может отличаться — из-за .default(), .transform() или .coerce. Поле с .default("user") необязательно на входе, но гарантированно есть на выходе. Если вы типизируете сырые данные, берите z.input<typeof Schema>, а для результата — z.infer (это алиас z.output). Смешение этих двух приводит к тому, что компилятор считает поле опциональным там, где оно уже заполнено дефолтом.
Куда двигаться дальше
Если валидируете входящие HTTP-запросы, посмотрите на интеграцию валидатора с вашим роутером — многие фреймворки принимают схему напрямую и сами возвращают 400 при несоответствии, избавляя от ручного safeParse в каждом хендлере. Для переменных окружения оберните схему в отдельный модуль, который валидирует process.env один раз при старте и падает сразу, а не через час работы. И держите правило: любые данные, пересекающие границу процесса, проходят через схему — as оставьте для случаев, где вы физически не можете проверить, но точно знаете тип.
Генерация схем из OpenAPI-описания бэкенда — отдельная тема, здесь её не разворачиваем; но если у вас есть спецификация API, ручное описание схем можно не поддерживать вручную.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Zod — официальная документация, Valibot — официальная документация
Частые вопросы
А если у меня Zod 4, там всё так же?
Валидация каждого ответа API не замедлит приложение?
Можно ли обойтись вообще без библиотеки?
Почему нельзя просто использовать as User после fetch?
Не сработало: parse проходит, но поле всё равно undefined в типе. Почему?
Что делать с вложенными объектами и массивами?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.