Единая схема валидации данных на фронтенде и бэкенде с Zod

Одно из самых уязвимых мест в любом веб-приложении - граница между клиентом и сервером. Разработчики тратят часы на создание согласованных форм с обратной связью на фронтенде и таких же структур проверки на бэкенде. А когда изменения входят в противоречие - начинаются тонкие ошибки, странное поведение и разочарованные пользователи.

Рассмотрим типичный сценарий: Вы добавляете новое поле в форму регистрации. Вносите изменения на фронтенде, обновляете бэкенд... и забываете изменить одну проверку на сервере. Приложение проходит тестирование, но через месяц появляются битые аккаунты. Знакомо?

Решение - единые схемы валидации, работающие на клиенте и сервере. Библиотека Zod предоставляет мощный инструмент для решения этой проблемы.

typescript
// Общая схема для user.entity.ts
import { z } from "zod";

export const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email("Некорректный адрес электронной почты"),
  password: z.string()
    .min(8, "Пароль должен содержать минимум 8 символов")
    .regex(/[A-Z]/, "Пароль должен содержать заглавную букву")
    .regex(/[0-9]/, "Пароль должен содержать цифру"),
  age: z.number().int().positive("Возраст должен быть положительным числом").min(18),
  registrationDate: z.date().optional()
});

export type User = z.infer<typeof UserSchema>;

Почему разработчики терпят поражение в войне с валидацией

Проблема начинается с дублирования кода:

  1. Фронтенд-проверки часто размещаются прямо в компонентах форм - быстро и удобно при создании, но:

    • Отсутствует переиспользование
    • Сложно поддерживать синхронизацию с сервером
    • Логика разбросана по разным компонентам
  2. Бэкенд-проверки обычно используют:

    • Ручные проверки в обработчиках
    • Автоматизацию через validation middleware
    • Классы DTO

Реальность: когда эти слои развиваются независимо, они неизбежно начинают конфликтовать. От пользователя скрывается часть требований к данным, сервер получает нежизнеспособные запросы, что приводит к ошибкам, которые вы видите в production.

Zod как решение в один код

Zod предлагает декларативный синтаксис для создания схем данных с выведением типов TypeScript - одна декларация определяет и функцию валидации, и интерфейс.

Сильные стороны:

  • Статическая типизация TypeScript из схем
  • Цепочка методов для комплексных проверок
  • Расширяемость через пользовательские валидации
  • Преобразование данных (parsing, трансформация)
  • Небольшой размер пакета (8kb) имеет значение для фронтенда

Интеграция на фронтенде (React)

Рассмотрим реализацию формы регистрации:

typescript
// Форма регистрации
import { UserSchema } from "@shared/schemas/user";

const RegistrationForm = () => {
  const [errors, setErrors] = useState<Record<string, string>>({});

  const handleSubmit = async (event) => {
    event.preventDefault();
    
    const formData = new FormData(event.target);
    const rawData = Object.fromEntries(formData);
    
    try {
      // Парсинг и валидация данных
      const userData = UserSchema.parse({
        ...rawData,
        age: Number(rawData.age) // Преобразование типа
      });
      
      // Отправка на сервер
      const response = await fetch('/api/register', {
        method: 'POST',
        body: JSON.stringify(userData)
      });
      
      // Обработка ответа
    } catch (error) {
      if (error instanceof z.ZodError) {
        // Собираем ошибки для UI
        const fieldErrors = {};
        error.errors.forEach(err => {
          fieldErrors[err.path[0]] = err.message;
        });
        setErrors(fieldErrors);
      }
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" />
      {errors.email && <span>{errors.email}</span>}
      
      <input name="password" type="password" />
      {errors.password && <span>{errors.password}</span>}
      
      <input name="age" type="number" />
      {errors.age && <span>{errors.age}</span>}
      
      <button type="submit">Зарегистрироваться</button>
    </form>
  );
};

Преимущество: схема, определяющая поля формы, также отвечает за пользовательское отображение ошибок. При изменении требований вы меняете только схему.

Серверная реализация с Express.js

На бэкенде та же схема гарантирует согласованность:

typescript
// Серверный обработчик регистрации
import express from 'express';
import { UserSchema } from "@shared/schemas/user";

const app = express();
app.use(express.json());

app.post('/api/register', (req, res) => {
  try {
    // Та же валидация!
    const userData = UserSchema.parse(req.body);
    
    // Работа с данными
    // Типизированные поля гарантированы системой типов
    const newUser = createUser(userData);
    
    res.status(201).json(newUser);
  } catch (error) {
    if (error instanceof z.ZodError) {
      // Консистентные ошибки с фронтендом
      res.status(400).json({
        error: "Invalid data",
        details: error.errors
      });
    } else {
      res.sendStatus(500);
    }
  }
});

Обратите внимание: при валидации отказов пользователь получает ошибку, структура которой идентична фронтенду. Это значительно упрощает обработку ошибок UI.

Продвинутые техники: выходим за рамки базовой валидации

Связанные валидации

Проверки, которые требуют контекста нескольких полей, можно выполнить с refine:

typescript
const EventSchema = z.object({
  startDate: z.date(),
  endDate: z.date(),
}).refine(data => data.endDate >= data.startDate, {
  message: "Конец события не может быть раньше начала",
  path: ["endDate"], // Целевое поле для сообщения об ошибке
});

Трансформации данных

Zod может преобразовывать данные во время валидации:

typescript
const PriceSchema = z.string().transform(val => {
  // Преобразование в число с проверкой формата
  const number = parseFloat(val);
  if (isNaN(number)) throw new Error("Invalid number format");
  return number;
}).refine(n => n > 0, "Цена должна быть положительной");

Составные схемы

Создавайте модульные правила и комбинируйте их:

typescript
const PasswordSchema = z.string()
  .min(8)
  .regex(/[A-Z]/)
  .regex(/[0-9]/);

const OptionalProfileSchema = z.object({
  displayName: z.string().optional(),
  age: z.number().optional(),
});

const FullUserSchema = UserSchema.merge(OptionalProfileSchema);

Оптимизация архитектуры приложения

Как структурировать схемы в реальном проекте?

  1. Создайте папку shared-schemas:

    • Экспортируемые схемы для сущностей
    • Схемы запросов/ответов API
    • Добавьте в общие зависимости (workspaces в monorepo)
  2. Версионирование схем:

    • При радикальных изменениях создавайте v2 со старыми версиями
    • Стадии перехода поддерживайте промежуточными версиями
  3. Интеграция с Swagger/OpenAPI:

    • zod-to-openapi автоматизирует документацию по схемам
typescript
// Пример документации
import { extendApi } from '@anatine/zod-openapi';

export const UserSchemaDocumented = extendApi(
  UserSchema, 
  {
    description: "Системный пользователь",
    example: {
      id: "00d6d171-ae6d-4dce-9d98-6dcef0ee57b9",
      email: "user@example.com"
    }
  }
);

Ошибки и усовершенствования

Распространенные ошибки интеграции:

  • Игнорирование ошибок zod: Обязательно проверяйте instanceof z.ZodError
  • Цепочки преобразований: Преобразования не выполняются при несоответствии типов
  • Миграции: Изменение схем требует одновременного развертывания фронтенда и бэкенда

Регулярно проводите аудит схем:

bash
# Проверка неиспользуемых схем
npx ts-unused-exports tsconfig.json --excludePaths migration-*.ts

Перспективные практики

Хотя Zod доминирует в TypeScript-экосистеме, другие решения тоже заслуживают внимания:

  • Yup: Хорош для фронтенда, но без TypeScript-инференса первого класса
  • Joi: Классика бэкенда, но заметно тяжелее для клиента
  • Superstruct: Альтернатива Zod с другим подходом

Однако Zod обеспечивает лучший баланс функционала, размера и статической типизации для полного стека.

Когда использовать единые схемы

Такой подход идеален для:

  • Приложений с сложными формами
  • Высоких требований к безопасности данных
  • Команд с полным стеком
  • Долгосрочных проектов

Возможно он избыточен для:

  • Предельно простых API
  • Прототипов
  • Систем с явно разделенными фронтенд/бэкенд командами

Граничные преимущества

Затраты на внедрение быстро окупаются:

  • Снижение дефектов межслоевого взаимодействия на 60-80%
  • Единство кодовой базы при изменениях требования
  • Автоматическая генерация TypeScript-интерфейсов
  • Предсказуемое поведение системы
  • Легкая документация

Разработчик, который однажды реализовал сквозную валидацию через Zod для критического потока данных, редко возвращается к ручным проверкам. Это напоминает переход от ручных миграций баз данных к версионным инструментам - трудозатраты перемещаются с исправлений и отладки на декларативное описание с единым источником истины.

Момент когда сервер возвращает "Unexpected field 'birthdate'" при получении отполированного фронтенда становится историей, мысленно заменяя шишки на корублином пути. Вместо этого оба слоя приложения едины в объектных структурах и правилах их формирования.