Синхронизация клиентской и серверной валидации

В веб-приложениях валидация данных традиционно разделяется между клиентской и серверной сторонами. Клиентская проверка обеспечивает быстрый отклик интерфейса и снижает количество некорректных запросов, серверная — гарантирует целостность данных и защищает систему от недоверенных входных значений.

Основная сложность возникает не в самой валидации, а в поддержании идентичности правил на обеих сторонах. Любое расхождение приводит к рассинхронизации поведения: данные могут проходить проверку на клиенте и отклоняться на сервере либо наоборот.


Причины расхождения логики валидации

Типичные источники несоответствий:

  • дублирование логики в разных слоях приложения;
  • использование разных технологий проверки (например, браузерные проверки vs backend-валидаторы);
  • ручное поддержание DTO без единого источника истины;
  • эволюция бизнес-правил, обновлённая только на одной стороне;
  • различия в интерпретации типов (строки vs числа, nullable vs optional).

При масштабировании системы проблема усиливается пропорционально количеству полей и форм.


Единый источник истины через схемы Zod

Библиотека Zod позволяет описывать структуру данных как runtime-схему, одновременно извлекая из неё статические типы TypeScript.

Ключевая идея заключается в том, что схема становится универсальным контрактом:

  • используется на клиенте для предварительной проверки;
  • используется на сервере как обязательная валидация;
  • служит источником типов для TypeScript.

Пример базовой схемы:

import { z } from "zod";

export const userSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  age: z.number().int().min(18),
});

Из схемы автоматически извлекается тип:

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

Таким образом устраняется необходимость вручную поддерживать отдельные DTO.


Архитектура совместного использования схем

Наиболее устойчивый подход — вынесение схем в общий пакет:

/packages
  /shared
    /schemas
      user.ts
      auth.ts
      product.ts

И подключение этого пакета как на клиенте, так и на сервере.

Такой подход устраняет дублирование и обеспечивает синхронное обновление правил валидации.


Клиентская валидация и безопасный разбор данных

На клиенте схемы используются для предварительной проверки форм и состояния UI.

const result = userSchema.safeParse(formData);

if (!result.success) {
  console.log(result.error.format());
}

Метод safeParse позволяет избежать исключений и работать с результатом как с объектом состояния.

Дополнительно используется refine для бизнес-логики:

const passwordSchema = z.string().min(8).refine(val => {
  return /[A-Z]/.test(val);
});

Клиентская валидация не рассматривается как защита, а лишь как улучшение UX.


Серверная валидация как источник истины

На сервере схема используется как обязательный фильтр входных данных.

app.post("/users", (req, res) => {
  const parsed = userSchema.safeParse(req.body);

  if (!parsed.success) {
    return res.status(400).json(parsed.error.format());
  }

  const user = parsed.data;
  // дальнейшая обработка
});

Любое отклонение данных блокируется до попадания в бизнес-логику.


Унификация формата ошибок

Zod формирует структурированный объект ошибок, который может быть приведён к единому API-формату.

const formatZodError = (error: z.ZodError) => {
  return error.issues.map(issue => ({
    path: issue.path.join("."),
    message: issue.message,
  }));
};

Это позволяет клиенту отображать ошибки без дополнительной интерпретации.


Типизация как побочный эффект схем

Сильная сторона Zod заключается в том, что типы выводятся автоматически:

type UserInput = z.input<typeof userSchema>;
type UserOutput = z.output<typeof userSchema>;

Разделение input/output типов важно при использовании трансформаций.


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

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

const schema = z.object({
  price: z.string().transform(val => Number(val)),
});

Это критично при работе с формами, где всё приходит в виде строк.


Частичные и составные схемы

Для различных сценариев используются производные схемы:

const updateUserSchema = userSchema.partial();
const publicUserSchema = userSchema.omit({ email: true });
const authSchema = userSchema.pick({ email: true });

Это позволяет переиспользовать структуру без дублирования.


Проблема несоответствия runtime и compile-time

TypeScript обеспечивает только статическую проверку. Runtime-данные остаются недоверенными.

Zod закрывает этот разрыв, обеспечивая:

  • runtime-валидацию;
  • синхронную типизацию;
  • единый контракт данных.

Сетевые ограничения и сериализация

Передача данных через JSON накладывает ограничения:

  • отсутствуют Date, Map, Set в исходном виде;
  • все числа приходят как number, но часто из форм как string;
  • null и undefined интерпретируются по-разному.

Zod позволяет компенсировать это через preprocess:

const schema = z.object({
  createdAt: z.preprocess(val => new Date(val as string), z.date()),
});

Интеграция с API-слоями

В REST или RPC слоях схемы становятся контрактом эндпоинта.

Пример с Express:

app.post("/login", (req, res) => {
  const result = loginSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json(result.error.format());
  }

  authenticate(result.data);
});

В Next.js API routes аналогичный подход:

export default function handler(req, res) {
  const parsed = schema.safeParse(req.body);

  if (!parsed.success) {
    return res.status(400).json(parsed.error.format());
  }

  res.json({ ok: true });
}

Синхронизация ошибок между слоями

UI-слой часто требует привязки ошибок к полям формы. Для этого используется нормализация:

const fieldErrors = Object.fromEntries(
  error.issues.map(i => [i.path[0], i.message])
);

Такой формат позволяет напрямую интегрировать ошибки в state менеджеры.


Версионирование схем

При изменении структуры данных возникает необходимость версионирования.

Подходы:

  • создание новой схемы (userSchemaV2);
  • использование extend:
const userV2 = userSchema.extend({
  nickname: z.string(),
});
  • мягкая миграция через optional и default.

Разделение ответственности схем

Схемы можно логически разделять:

  • входные данные (input schema);
  • доменные модели (domain schema);
  • выходные данные API (response schema).
const createUserInput = z.object({...});
const userDomain = createUserInput.extend({ id: z.string() });
const userResponse = userDomain.omit({ password: true });

Повторное использование и предотвращение дублирования

Синхронизация достигается за счёт:

  • единого пакета схем;
  • отсутствия локальных валидаторов;
  • запрета на ручное копирование правил;
  • компоновки схем через merge, extend, intersection.

Тестирование схем

Схемы рассматриваются как самостоятельные единицы логики.

Пример теста:

expect(userSchema.safeParse({ email: "bad" }).success).toBe(false);

Тестирование позволяет фиксировать контракт независимо от UI и API.


Безопасность валидации

Серверная проверка через Zod снижает риск:

  • инъекций через неконтролируемые поля;
  • некорректных типов данных;
  • обхода клиентской логики.

Однако схемы не заменяют бизнес-валидацию, а дополняют её.


Проблема избыточной логики

Чрезмерное усложнение схем приводит к:

  • трудной читаемости;
  • смешению бизнес-логики и валидации;
  • увеличению стоимости изменений.

Поэтому сложные проверки часто выносятся в отдельные функции, используемые внутри refine.


Итоговая модель синхронизации

Использование Zod формирует единый контур данных:

  • схема как контракт;
  • тип как производное;
  • клиент и сервер как потребители одной модели;
  • ошибки как унифицированный формат;
  • трансформации как слой нормализации.

Такой подход устраняет необходимость ручной синхронизации валидационных правил между частями системы и снижает вероятность расхождений поведения на разных уровнях приложения.