Одно из самых уязвимых мест в любом веб-приложении - граница между клиентом и сервером. Разработчики тратят часы на создание согласованных форм с обратной связью на фронтенде и таких же структур проверки на бэкенде. А когда изменения входят в противоречие - начинаются тонкие ошибки, странное поведение и разочарованные пользователи.
Рассмотрим типичный сценарий: Вы добавляете новое поле в форму регистрации. Вносите изменения на фронтенде, обновляете бэкенд... и забываете изменить одну проверку на сервере. Приложение проходит тестирование, но через месяц появляются битые аккаунты. Знакомо?
Решение - единые схемы валидации, работающие на клиенте и сервере. Библиотека Zod предоставляет мощный инструмент для решения этой проблемы.
// Общая схема для 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>;
Почему разработчики терпят поражение в войне с валидацией
Проблема начинается с дублирования кода:
-
Фронтенд-проверки часто размещаются прямо в компонентах форм - быстро и удобно при создании, но:
- Отсутствует переиспользование
- Сложно поддерживать синхронизацию с сервером
- Логика разбросана по разным компонентам
-
Бэкенд-проверки обычно используют:
- Ручные проверки в обработчиках
- Автоматизацию через validation middleware
- Классы DTO
Реальность: когда эти слои развиваются независимо, они неизбежно начинают конфликтовать. От пользователя скрывается часть требований к данным, сервер получает нежизнеспособные запросы, что приводит к ошибкам, которые вы видите в production.
Zod как решение в один код
Zod предлагает декларативный синтаксис для создания схем данных с выведением типов TypeScript - одна декларация определяет и функцию валидации, и интерфейс.
Сильные стороны:
- Статическая типизация TypeScript из схем
- Цепочка методов для комплексных проверок
- Расширяемость через пользовательские валидации
- Преобразование данных (parsing, трансформация)
- Небольшой размер пакета (8kb) имеет значение для фронтенда
Интеграция на фронтенде (React)
Рассмотрим реализацию формы регистрации:
// Форма регистрации
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
На бэкенде та же схема гарантирует согласованность:
// Серверный обработчик регистрации
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
:
const EventSchema = z.object({
startDate: z.date(),
endDate: z.date(),
}).refine(data => data.endDate >= data.startDate, {
message: "Конец события не может быть раньше начала",
path: ["endDate"], // Целевое поле для сообщения об ошибке
});
Трансформации данных
Zod может преобразовывать данные во время валидации:
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, "Цена должна быть положительной");
Составные схемы
Создавайте модульные правила и комбинируйте их:
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);
Оптимизация архитектуры приложения
Как структурировать схемы в реальном проекте?
-
Создайте папку
shared-schemas
:- Экспортируемые схемы для сущностей
- Схемы запросов/ответов API
- Добавьте в общие зависимости (workspaces в monorepo)
-
Версионирование схем:
- При радикальных изменениях создавайте v2 со старыми версиями
- Стадии перехода поддерживайте промежуточными версиями
-
Интеграция с Swagger/OpenAPI:
zod-to-openapi
автоматизирует документацию по схемам
// Пример документации
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
- Цепочки преобразований: Преобразования не выполняются при несоответствии типов
- Миграции: Изменение схем требует одновременного развертывания фронтенда и бэкенда
Регулярно проводите аудит схем:
# Проверка неиспользуемых схем
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'" при получении отполированного фронтенда становится историей, мысленно заменяя шишки на корублином пути. Вместо этого оба слоя приложения едины в объектных структурах и правилах их формирования.