
Всем привет, с вами Артем Леванов, Front Lead в компании WebRise.
В прошлой статье мы разобрали, как навести порядок в создании форм — выделили примитивы, ячейки и типовые поля.
Следующая проблема, с которой сталкивается любая форма — валидация.
Формы могут быть красивыми и структурными, но без единого подхода к валидации они быстро превращаются в хаос.
В этой статье поговорим о том, почему встроенные и кастомные проверки плохо масштабируются, особенно в динамических формах, и как Zod решает эту проблему, превращая валидацию в декларативную и типобезопасную систему.
Что такое Zod
Zod — это библиотека для декларативного описания структуры данных. Она не просто проверяет значения, а описывает правила так, чтобы они были понятны коду, разработчику, и TypeScript типам одновременно.
Добавить Zod в проект достаточно просто:
1. Устанавливаем зависимость
npm i zod
2. Описываем схему валидации для формы
const LoginFormSchema = z.object({ email: z.email('Некорректный email'), password: z.string().min(2, 'Пароль слишком короткий'), }); type LoginFormType = z.infer<typeof LoginFormSchema>;
3. Подключаем схему через zodResolver, предоставляемый React Hook Form
const methods = useForm<LoginFormType>({ resolver: zodResolver(LoginFormSchema), mode: 'onSubmit', });
После этого форма получает автоматические типы, подсветку ошибок и единое место, где живут все правила. Полную версию формы можно посмотреть в демо на codeSanbox
Проблемы валидации и их решения на основе Zod
Громоздкий код валидации
Без использования схем код валидации часто оказывается разбросанным по проекту и выглядит примерно так:
const onSubmit = (data: any) => { const errors: Record<string, string> = {}; if (!data.email) { errors.email = 'Email обязателен'; } else if (!data.email.includes('@')) { errors.email = 'Некорректный email'; } if (!data.password) { errors.password = 'Пароль обязателен'; } else if (data.password.length < 8) { errors.password = 'Минимум 8 символов'; } if (Object.keys(errors).length > 0) { setFormErrors(errors); return; } // отправка данных; };
Zod позволяет писать код компактнее и строго в определенном месте.
const LoginFormSchema = z.object({ email: z.string().nonempty("Поле обязательно").check(z.email("Некорректный email")), password: z.string().min(1, 'Пароль обязателен').min(8, 'Минимум 8 символов'), });
Отсутствие строгой типизации данных
Как пример, пользователь может ввести в поле “двадцать“ и типизация TypeScript уже не будет работать
interface FormData { email: string; age: number; // но пользователь может ввести не число! Да и input всегда возвращает строку } const { register, handleSubmit } = useForm<FormData>(); // В UI: <input {...register('age')} />; // пользователь ввёл строкой "двадцать" // На сервере: parseInt("двадцать") → NaN
Zod решает эту проблему и подсвечивает пользователю ошибку:
const RegistrationFormSchema = z.object({ email: z.email('Некорректный email'), age: z.coerce.number({ invalid_type_error: 'Возраст должен быть числом', // если тип не `number` }), password: z.string().min(2, 'Пароль слишком короткий'), });
Схема сама приводит строку к числу и выдаёт понятную ошибку, если приведение невозможно.
Подготовка данных перед отправкой на бэк
Как правило, данные перед отправкой на бэк надо подготовить. Обрезать пробелы спереди и сзади, привести к строчному виду и т.д. Это требует отдельного места, где этот процесс проиcходит, обычно это делают в функции onSubmit, которая сильно раздувается
Zod позволяет прямо в схеме преобразовать данные до нужного вида
name: z.string().transform(str => str.trim())
onSubmit остаётся чистым и делает то, что должен — отправляет данные.
Сложности асинхронной валидации
Проверка асинхронной валидации требует добавления различных состояний и обработчиков.
const [isChecking, setIsChecking] = useState(false); const [emailError, setEmailError] = useState(''); const checkEmailAvailability = async (email: string) => { const res = await fetch(`/api/check-email?email=${email}`); if (!res.ok) throw new Error('Ошибка проверки логина'); return res.json() as Promise<boolean>; }; const checkEmail = async (value: string) => { setIsChecking(true); const isAvailable = await checkEmailAvailability(value); setIsChecking(false); if (!isAvailable) { setEmailError('Логин уже занят'); } else { setEmailError(''); } }; <input {...register('email', { onChange: (e) => checkEmail(e.target.value), })} />; {isChecking && <span>Проверяем...</span>;} {emailError && <span>{emailError}</span>;}
При использовании zod, схема становится единым источником истины для синхронных и асинхронных проверок, а связка React Hook Form + zodResolver берут на себя статусы и ошибки, не требуя ручных состояний.
const RegistrationFormSchema = z.object({ email: z .email('Некорректный email') .refine( async (value) => { const isAvailable = await checkEmailAvailability(value); return isAvailable; }, { message: 'Логин уже занят' } ), password: z.string().min(8, 'Минимум 8 символов'), });
Схема остаётся д��кларативной, а RHF берет на себя управление состояниями. Стоит отметить, что вся валидация становится асинхронной и запускать ее лучше через mode submit или использовать debounce.
Валидации взаимосвязанных полей
Как только форма становится немного сложнее пары полей, появляются зависимости: телефон обязателен, только если выбран чекбокс, документ обязателен лишь после определённого возраста и т. д.
const onSubmit = (data: any) => { const errors: Record<string, string> = {}; const regexPhone = /^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$/; if (data.hasPhone) { if (!data.phone) { errors.phone = 'Укажите телефон'; } else if (!regexPhone.test(data.phone)) { errors.phone = 'Неверный формат'; } } if (data.age > 18 && !data.documentId) { errors.documentId= 'Укажите номер документа'; } if (Object.keys(errors).length) { setFormErrors(errors); return; } // отправка данных формы };
Zod позволяет вынести эту логику в схему и предоставляет инструменты (refine, superRefine) для описания правил валидации между полями
const regexPhone = /^\\+7 \\(\\d{3}\\) \\d{3}-\\d{2}-\\d{2}$/; const RegistrationFormSchema= z.object({ hasPhone: z.boolean().optional(), email: z.string().nonempty("Поле обязательно").check(z.email("Некоректный email")), phone: z.string().regex(regexPhone, 'Телефон должен быть в формате +7 (999) 999-99-99').optional(), age: z.coerce .number({ invalid_type_error: 'Возраст должен быть числом', }) .min(1, 'Укажите возраст'), documentId: z.coerce .number({ invalid_type_error: 'Номер документа должен быть числом', }) .optional(), }) // Валидация проходит, если телефон не требуется или он указан. .refine((data) => !data.hasPhone || !!data.phone, { path: ['phone'], message: 'Укажите телефон', }) // Валидация прох��дит, если возраст не больше 18 или указан номер документа. .refine((data) => !(data.age > 18) || !!data.documentId, { path: ['documentId'], message: 'Укажите номер документа', });
Валидация динамических полей
Формы, в которых пользователь может добавлять поля “на лету” (например, список адресов, телефонов или документов), требуют особого внимания. Разработчик сталкивается с целым набором технических задач:
описание типов
подготовка ui для удаления и выведения новых полей
написание системы редактирования проверок валидации “на лету“
серверные проверки
Zod в связке с React Hook Form эффективно решает эти проблемы и снижает объём и сложность кода.
addresses: z.array( z.object({ zipCode: z.string().min(1, 'Укажите индекс'), city: z.string().min(1, 'Укажите город'), }) ),
Полный пример работы такой формы можно посмотреть демке
Таким образом, Zod позволяет держать типы и валидацию в одном месте, а RHF — эффективно управлять динамическими массивами полей
Преимущества Zod
Помимо решения стандартных проблем валидации, Zod дает дополнительные преимущества.
Автоматическое выведение типов из схемы
Создавая схему, мы одновременно описываем структуру данных и получаем типы:
const UserSchema = z.object({ id: z.number(), name: z.string(), }); type User = z.infer<typeof UserSchema>; // готовый тип
Повторное использование схем
Схемы удобно переиспользовать между фронтом и бэком, или между разными похожими формами. Например формы редактирования и создания пользователя, где состав полей может отличаться только на id
const UserSchema = z.object({ ... }); const CreateUserSchema = UserSchema.omit({ id: true });
Валидация входящих данных в runtime
TypeScript защищает только на этапе компиляции.
Zod же позволяет валидировать данные в рантайме, например — ответы API, query-параметры или содержимое localStorage
const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.email(), }); fetch('/api/user/1') .then((res) => res.json()) .then((data) => { const user = UserSchema.parse(data); // проверка в рантайме console.log(user.name); }) .catch((err) => { if (err instanceof z.ZodError) { console.error('Некорректные данные от API:', err.errors); } else { console.error('Ошибка запроса:', err); } });
Итоги
Zod помогает привести валидацию к предсказуемой и декларативной системе: структура данных, типы и правила находятся в одном месте, а React Hook Form берёт на себя техническую часть работы с формой. Такой подход отлично масштабируется и остаётся управляемым даже при усложнении сценариев — динамические поля, асинхронные проверки, зависимости между значениями.
В первой статье мы стандартизировали компонентный слой форм — примитивы, ячейки и типовые поля. Добавив Zod как формальный язык для описания валидации, мы получили завершённую модель, где UI и правила валидации описаны единообразно и могут использоваться совместно. Такой подход облегчает поддержку, снижает количество ручного кода и создаёт фундамент для автоматизации.
По вопросам, телеграм @webrise1
