Zod: строгая валидация и удобная типизация. Опыт перехода
- среда, 11 февраля 2026 г. в 00:00:07

Привет, Хабр! Меня зовут Сергей, я фронтенд-инженер в Банки.ру.
В этой статье расскажу, как Zod помог нам перестать писать валидацию на уровне полей, подружился с React Hook Form и стал единым источником правды о структуре данных.
К Zod мы пришли не сразу. Долгое время типы и валидация у нас жили в разных слоях приложения: TypeScript определял структуру данных во время разработки, а отдельные функции или библиотеки (вроде Yup) проверяли входящие значения в рантайме.
Это классическая проблема: дублирование логики и рассинхрон. Типы в interface поменялись, а валидация осталась прежней (или наоборот). Мы пробовали Yup, но он казался громоздким в связке с TS: типы приходилось выводить вручную или мириться с тем, что схемы выглядят непрозрачно. В какой-то момент стало непонятно: зачем тащить отдельную библиотеку, если проще написать if (typeof x === 'string')?
С переходом на Zod всё стало значительно проще: одна схема одновременно является и валидатором, и источником типа данных.
Zod – это компактная zero-dependency библиотека для валидации и создания схем данных. Проверки описываются декларативно, без длинных громоздких условий, и за счет этого легко читаются и становятся похожи на функции. Примеры будут ниже в тексте.
В этой простоте – вся суть Zod. Он не тянет за собой лишние зависимости и абсолютно прозрачно и предсказуемо делает только, что от нее ждет пользователь, и ничего больше.
Zod хорошо показывает себя на средних и крупных проектах, где нужно много валидировать входящие данные. Для простого лендинга он может быть слишком громоздким, а для очень масштабных, глубоко вложенных схемах – наоборот, недостаточно производительным и медленным. Но на большинстве проектов Zod обеспечивает отличный Developer Experience и избавляет от частых проблем, возникающих при валидации данных.
Zod изначально спроектирован для TypeScript – это его киллер-фича по сравнению с «классическими» JS-валидаторами. В ней типы данных извлекаются (infer) полуавтоматически из схемы валидации, и поэтому не приходится писать их руками. Больше не нужно поддерживать два параллельных файла (один с interface, другой со схемой).
Сначала мы описываем тип для TS:
interface IUser { id: number; username: string; email: string; }
Затем отдельно пишем валидатор, дублируя структуру:
// Дублирование логики! const yupValidator = yup.object({ id: yup.number().required(), username: yup.string().required(), email: yup.string().email().required(), });
Если добавить поле в интерфейс, валидатор об этом не узнает.
Рассинхронизация гарантирована.
Мы описываем схему один раз:
import { z } from "zod"; const UserSchema = z.object({ id: z.number().int(), username: z.string().min(3), email: z.string().email(), });
И получаем TypeScript-тип автоматически:
type User = z.infer<typeof UserSchema>; // TypeScript автоматически понимает, что User это: // { id: number; username: string; email: string; }
Теперь схема Zod – единственный источник правды. Если вы измените .string() на .number() в схеме, TS тут же подсветит ошибки во всем проекте, где этот тип используется неправильно:

TypeScript защищает нас только до момента компиляции. Как только код попадает в браузер, возникает «слепое пятно», где любая информация извне – это неизвестность (unknown или any):
данные из форм;
ответы от бэкенда (API может измениться без предупреждения);
данные из localStorage или URL query params.
Наивное приведение типов: const data = response.json() as User;
Это самообман. Даже когда TS утверждает, что всё ок, у пользователя приложение может упасть (runtime error), если бэкенд вернет null вместо массива
Ручные проверки (Type Guards):if (data && typeof data.name === 'string'...)
Это громоздко, плохо читается и требует ручной поддержки.
Мы можем валидировать данные декларативно в момент их получения:
const UserSchema = z.object({ name: z.string(), }); type User = z.infer<typeof UserSchema>; // Моковые данные (fallback) const MOCK_USER: User = { name: 'Anonymous' }; export function fetchUser(id: number): User { const rawData = getUserData(); // Допустим, это unknown ответ от API // safeParse не бросает ошибку, а возвращает объект результата const result = UserSchema.safeParse(rawData); if (!result.success) { console.error('[fetchUser] Zod validation failed', { userId: id, issues: result.error.issues, // Детальное описание, что пошло не так rawData, }); // Возвращаем безопасный фоллбэк или прокидываем ошибку дальше return MOCK_USER; } // Здесь TS уже знает, что result.data – это точно тип User return result.data; }
Метод .safeParse() работает как Type Guard. Если на бэкенде что-то изменится (например, name станет числом), мы узнаем об этом сразу в точке входа данных, а не получим «белый экран» при попытке вызвать name.toUpperCase() в React-компоненте.
Zod позволяет гибко настраивать, как относиться к «лишним» полям в объекте, которых нет в схеме.
const BaseUser = z.object({ id: z.number(), name: z.string(), }); const input = { id: 1, name: "Rashida", extra: "Lorem pixel" };
Лишние поля разрешены на входе, но молча вырезаются из результата.
Идеально для API: мы получаем только те поля, которые описали и ожидаем. Мусор не попадает в приложение.
BaseUser.parse(input); // Результат: { id: 1, name: "Rashida" } // Поле 'extra' исчезло
Лишние поля запрещены. Если придет хоть что-то, чего нет в схеме – будет ошибка валидации (throw error).
Идеально, когда нужна жесткая контрактация, и наличие лишних полей считается ошибкой протокола.
const UserStrict = BaseUser.strict(); UserStrict.parse(input); // ZodError: Unrecognized key(s) in object: 'extra'
Лишние поля разрешены и остаются в объекте.
Полезно для проксирования данных или логирования, когда важна часть структуры, но остальное нужно сохранить «как есть».
const UserPassthrough = BaseUser.passthrough(); UserPassthrough.parse(input); // Результат: { id: 1, name: "Rashida", extra: "Lorem pixel" }
Совет: Для большей читаемости кода в современных версиях Zod рекомендуется использовать явные конструкторы: z.strictObject(...) вместо z.object(...).strict() и z.looseObject() вместо z.object(...).passthrough(). Варианты записи с strict() и passthrough() считаются устаревшими, но еще поддерживаются.
Zod имеет богатую экосистему и нативно работает с популярными библиотеками и фреймворками – на официальном сайте указано много всего, вклюая tRPC, NestJS и React Нас особенно интересовала интеграция с React Hook Form.
Обычно валидация в формах – это боль: нужно писать правила для каждого input, прокидывать ошибки... С Zod мы подключаем zodResolver, и вся магия происходит сама.
zodResolver – это адаптер, который учит RHF понимать схемы Zod. RHF запускает валидацию через Zod, получает ошибки, мапит их на поля формы и подсвечивает UI.
Больше не нужно писать валидацию внутри компонентов – она живет в схеме.
Давайте рассмотрим более сложный пример. Zod умеет не только проверять типы, но и трансформировать данные (нормализация) и выполнять асинхронные проверки (запрос к БД).
// Имитация запроса к API async function isEmailAvailable(email: string): Promise<boolean> { await new Promise((r) => setTimeout(r, 200)); return email.toLowerCase() !== "taken@example.com"; } const UserRegistrationSchema = z.object({ age: z .number() .int('Возраст должен быть целым числом') .gte(18, 'Вам должно быть 18 лет') .max(100, 'Кажется, вы слишком стары для этого'), email: z .string() .trim() // 1. Сначала убираем пробелы .toLowerCase() // 2. Приводим к нижнему регистру .email('Некорректный формат email') // 3. Проверяем формат .refine( async (email) => { // 4. Асинхронная проверка уникальности return await isEmailAvailable(email); }, { message: 'Пользователь с таким email уже существует' } ), }); // ⚠️ Важно: для async-валидатора нужно использовать parseAsync/safeParseAsync async function validateUser(data: unknown) { const result = await UserRegistrationSchema.safeParseAsync(data); if (!result.success) { // Удобный формат ошибок console.log(result.error.flatten().fieldErrors); return; } // result.data.email здесь уже будет trim'нутый и в lowerCase! console.log("Успех:", result.data); }
1) Цепочки правил.
Мы нанизываем проверки одну за другой (.number().int().gte(...)).
2) Трансформации (.trim(), .toLowerCase()).
Схема не только проверяет, но и меняет данные. На выходе из parse мы получаем уже нормализованный email, для которого не нужно делать email.trim() вручную перед отправкой на сервер.
3) Refine (уточнение).
Метод .refine позволяет писать любую кастомную логику, включая асинхронные запросы к API.
Для нас в Банки.ру Zod стал стандартом де-факто при работе с внешними данными.
Плюсы:
– DX (Developer Experience): понятные, легко читающиеся схемы, еще и автодополнение кода работает божественно.
– Безопасность: Zod схема может проверить входящие данные до того, как они попадут в использующий их скрипт, и либо выдать типизированные данные, либо fallback.
– Экосистема: zodResolver для форм, интеграции с tRPC, NestJS и даже Prisma.
– Компактность: никаких лишних зависимостей и сложностей, установили и начали работу.
Минусы (на что обратить внимание):
– Избыточное усложнение: Zod может быть избыточен для сверх-оптимизированных лендингов.
– Async: Нуж��о быть внимательным с асинхронными refine в формах, чтобы не «спамить» запросами к серверу при каждом нажатии клавиши (нужен debounce).
В большинстве задач – от форм до валидации ответов API – Zod делает код чище, а сон разработчика крепче.