Структура против хаоса — практическая валидация форм с помощью Zod
- среда, 19 ноября 2025 г. в 00:00:04

Всем привет, с вами Артем Леванов, Front Lead в компании WebRise.
В прошлой статье мы разобрали, как навести порядок в создании форм — выделили примитивы, ячейки и типовые поля.
Следующая проблема, с которой сталкивается любая форма — валидация.
Формы могут быть красивыми и структурными, но без единого подхода к валидации они быстро превращаются в хаос.
В этой статье поговорим о том, почему встроенные и кастомные проверки плохо масштабируются, особенно в динамических формах, и как Zod решает эту проблему, превращая валидацию в декларативную и типобезопасную систему.
Zod — это библиотека для декларативного описания структуры данных. Она не просто проверяет значения, а описывает правила так, чтобы они были понятны коду, разработчику, и TypeScript типам одновременно.
Добавить Zod в проект достаточно просто:
1. Устанавливаем зависимость
npm i zod2. Описываем схему валидации для формы
const LoginFormSchema = z.object({
email: z.string().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
Без использования схем код валидации часто оказывается разбросанным по проекту и выглядит примерно так:
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().min(1, 'Email обязателен').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.string().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
.string()
.min(1, 'Укажите email')
.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().min(1, 'Укажите email').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 дает дополнительные преимущества.
Создавая схему, мы одновременно описываем структуру данных и получаем типы:
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 });TypeScript защищает только на этапе компиляции.
Zod же позволяет валидировать данные в рантайме, например — ответы API, query-параметры или содержимое localStorage
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().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