javascript

Структура против хаоса — практическая валидация форм с помощью Zod

  • среда, 19 ноября 2025 г. в 00:00:04
https://habr.com/ru/articles/967540/

Всем привет, с вами Артем Леванов, Front Lead в компании WebRise.

В прошлой статье мы разобрали, как навести порядок в создании форм — выделили примитивы, ячейки и типовые поля.

Следующая проблема, с которой сталкивается любая форма — валидация.

Формы могут быть красивыми и структурными, но без единого подхода к валидации они быстро превращаются в хаос.

В этой статье поговорим о том, почему встроенные и кастомные проверки плохо масштабируются, особенно в динамических формах, и как Zod решает эту проблему, превращая валидацию в декларативную и типобезопасную систему.

Что такое Zod

Zod — это библиотека для декларативного описания структуры данных. Она не просто проверяет значения, а описывает правила так, чтобы они были понятны коду, разработчику, и TypeScript типам одновременно.

Добавить Zod в проект достаточно просто:

1. Устанавливаем зависимость

npm i zod

2. Описываем схему валидации для формы

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

Проблемы валидации и их решения на основе 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().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

Помимо решения стандартных проблем валидации, 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.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