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

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

  • форма на клиенте проверяет email;
  • сервер выполняет ту же проверку повторно;
  • мобильное приложение содержит третью реализацию;
  • документация API описывает четвёртый вариант правил.

Подобная схема быстро приводит к рассинхронизации. В одном месте поле обязательно, в другом — нет. На клиенте пароль допускает 8 символов, а сервер требует минимум 12. В результате:

  • пользователь получает неожиданные ошибки;
  • frontend и backend начинают зависеть друг от друга;
  • увеличивается количество багов;
  • усложняется сопровождение системы.

class-validator позволяет централизовать правила проверки данных через классы и декораторы. Особенно эффективно библиотека работает вместе с:

  • class-transformer;
  • NestJS;
  • DTO-архитектурой;
  • монорепозиториями;
  • общими пакетами типов.

Общая модель DTO между клиентом и сервером

Проблема дублирования правил

Типичный backend DTO:

export class CreateUserDto {
  @IsEmail()
  email: string;

  @MinLength(8)
  password: string;
}

Frontend-форма часто реализует проверки отдельно:

if (!email.includes("@")) {
  showError();
}

if (password.length < 8) {
  showError();
}

Такой подход приводит к расхождению логики.


Единый пакет DTO

Наиболее распространённая архитектура — вынесение DTO в отдельный пакет.

Структура проекта:

packages/
  shared/
    dto/
      create-user.dto.ts

apps/
  frontend/
  backend/

DTO:

import { IsEmail, MinLength } from "class-validator";

export class CreateUserDto {
  @IsEmail()
  email: string;

  @MinLength(8)
  password: string;
}

Backend:

import { CreateUserDto } from "@shared/dto";

Frontend:

import { CreateUserDto } from "@shared/dto";

Теперь:

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

Использование class-validator на клиенте

Установка

npm install class-validator class-transformer

Для работы декораторов:

{
  "experimentalDecorators": true,
  "emitDecoratorMetadata": true
}

Проверка формы через validate

DTO:

import {
  IsEmail,
  MinLength,
  IsNotEmpty,
} from "class-validator";

export class RegisterDto {
  @IsEmail()
  email: string;

  @MinLength(8)
  password: string;

  @IsNotEmpty()
  name: string;
}

Валидация:

import { validate } from "class-validator";
import { plainToInstance } from "class-transformer";

async function validateForm(data: unknown) {
  const dto = plainToInstance(RegisterDto, data);

  const errors = await validate(dto);

  return errors;
}

Преобразование plain object в экземпляр класса

class-validator работает только с экземплярами классов.

Неправильно:

const data = {
  email: "test@test.com",
};

await validate(data);

Правильно:

const dto = plainToInstance(RegisterDto, data);

await validate(dto);

Формат ValidationError

Ошибки имеют древовидную структуру.

Пример:

[
  {
    property: "password",
    constraints: {
      minLength: "password must be longer than or equal to 8 characters"
    }
  }
]

Нормализация ошибок между клиентом и сервером

Единый формат ошибок

Одна из важнейших задач синхронизации — одинаковая структура ответов.

Например:

type ValidationErrors = {
  [key: string]: string[];
};

Функция преобразования:

import { ValidationError } from "class-validator";

export function mapErrors(
  errors: ValidationError[],
) {
  const result: Record<string, string[]> = {};

  for (const error of errors) {
    result[error.property] = Object.values(
      error.constraints || {},
    );
  }

  return result;
}

Результат:

{
  "email": [
    "email must be an email"
  ],
  "password": [
    "password must be longer than or equal to 8 characters"
  ]
}

Одинаковое отображение ошибок

Frontend может использовать одинаковую структуру независимо от источника:

{
  email: ["Некорректный email"]
}

Это позволяет:

  • не разделять клиентские и серверные ошибки;
  • отображать сообщения единым механизмом;
  • переиспользовать UI-компоненты.

Повторная валидация на сервере

Даже если frontend уже проверил данные, backend обязан валидировать их повторно.

Причины:

  • клиенту нельзя доверять;
  • запросы могут приходить напрямую;
  • frontend-проверки легко отключаются;
  • API может использоваться сторонними сервисами.

Валидация в NestJS

ValidationPipe

На сервере чаще всего используется ValidationPipe.

Подключение:

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    transform: true,
    forbidNonWhitelisted: true,
  }),
);

Роль transform

transform: true

Автоматически преобразует plain object в экземпляр класса.

Без этого декораторы могут работать некорректно.


whitelist

whitelist: true

Удаляет поля, отсутствующие в DTO.

Пример:

Входящие данные:

{
  "email": "test@test.com",
  "password": "12345678",
  "role": "admin"
}

DTO:

export class RegisterDto {
  @IsEmail()
  email: string;

  @MinLength(8)
  password: string;
}

После обработки:

{
  "email": "test@test.com",
  "password": "12345678"
}

forbidNonWhitelisted

forbidNonWhitelisted: true

Вместо удаления лишних полей выбрасывает ошибку.

Это особенно важно для:

  • безопасности;
  • защиты от mass assignment;
  • контроля API-контрактов.

Использование одинаковых DTO в React

Интеграция с React Hook Form

Пример адаптера:

import { validate } from "class-validator";
import { plainToInstance } from "class-transformer";

export async function validatorResolver(
  dtoClass,
  values,
) {
  const instance = plainToInstance(
    dtoClass,
    values,
  );

  const errors = await validate(instance);

  return {
    values,
    errors,
  };
}

Интеграция с Formik

const validateForm = async (values) => {
  const dto = plainToInstance(
    RegisterDto,
    values,
  );

  const errors = await validate(dto);

  return mapErrors(errors);
};

Группы валидации

Разные правила для разных сценариев

Одно и то же DTO может валидироваться по-разному.

Пример:

export class UserDto {
  @IsEmail()
  email: string;

  @MinLength(8, {
    groups: ["create"],
  })
  password: string;
}

Проверка:

validate(dto, {
  groups: ["create"],
});

Сценарий create/update

export class UpdateUserDto {
  @IsOptional()
  @MinLength(8)
  password?: string;
}

Или через группы:

export class UserDto {
  @IsNotEmpty({
    groups: ["create"],
  })
  name: string;
}

Частичная валидация PATCH-запросов

PATCH отличается от POST тем, что поля необязательны.

Проблема:

@MinLength(8)
password?: string;

Если поле отсутствует, validator всё равно может выполнить проверку.

Решение:

@IsOptional()
@MinLength(8)
password?: string;

Наследование DTO

Переиспользование правил

Базовый DTO:

export class BaseUserDto {
  @IsEmail()
  email: string;
}

Наследование:

export class CreateUserDto
  extends BaseUserDto {

  @MinLength(8)
  password: string;
}

PartialType в NestJS

Для update DTO:

export class UpdateUserDto
  extends PartialType(CreateUserDto) {}

Все поля становятся optional автоматически.


Вложенные DTO

Синхронизация сложных структур

Адрес пользователя:

export class AddressDto {
  @IsString()
  city: string;

  @IsString()
  street: string;
}

Основной DTO:

export class UserDto {
  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto;
}

Почему нужен @Type

Без @Type вложенный объект не преобразуется в экземпляр класса.

Неправильно:

@ValidateNested()
address: AddressDto;

Правильно:

@ValidateNested()
@Type(() => AddressDto)
address: AddressDto;

Массивы объектов

export class ProductDto {
  @IsString()
  title: string;
}
export class OrderDto {
  @ValidateNested({ each: true })
  @Type(() => ProductDto)
  products: ProductDto[];
}

Условная валидация

ValidateIf

export class PaymentDto {
  @IsString()
  type: string;

  @ValidateIf(o => o.type === "card")
  @IsNotEmpty()
  cardNumber: string;
}

Поле валидируется только при выполнении условия.


Кастомные валидаторы

Общая бизнес-логика

Сервер и клиент могут использовать одинаковые кастомные проверки.

Пример:

import {
  ValidatorConstraint,
  ValidatorConstraintInterface,
} from "class-validator";

@ValidatorConstraint()
export class IsStrongPasswordConstraint
  implements ValidatorConstraintInterface {

  validate(value: string) {
    return /[A-Z]/.test(value)
      && /[0-9]/.test(value);
  }
}

Декоратор:

export function IsStrongPassword() {
  return Validate(
    IsStrongPasswordConstraint,
  );
}

Ограничения кастомных валидаторов

Следует избегать в shared DTO:

  • доступа к БД;
  • HTTP-запросов;
  • серверных зависимостей;
  • Node.js API, отсутствующих в браузере.

Иначе общий пакет перестанет быть универсальным.


Асинхронная валидация

Проверка уникальности email

@ValidatorConstraint({ async: true })
export class UniqueEmailConstraint {
  async validate(email: string) {
    return !(await userExists(email));
  }
}

Подобные проверки обычно выполняются только на сервере.


Разделение клиентских и серверных правил

На практике полезно разделять:

  • синхронные универсальные проверки;
  • серверные проверки бизнес-логики.

Пример:

@IsEmail()
email: string;

На сервере дополнительно:

@Validate(UniqueEmailConstraint)
email: string;

Версионирование DTO

Проблема изменений API

Если frontend и backend обновляются независимо, DTO могут стать несовместимыми.

Например:

@IsString()
username: string;

Позже:

@Length(5, 20)
username: string;

Старый frontend может не учитывать новые ограничения.


Стратегии совместимости

Семантическое версионирование

shared-dto@1.2.0

Изменения правил валидации:

  • patch — исправления;
  • minor — новые необязательные поля;
  • major — breaking changes.

Отдельные DTO версии API

CreateUserV1Dto
CreateUserV2Dto

Подход особенно полезен для публичных API.


Генерация схем для frontend

class-validator + OpenAPI

В NestJS:

@ApiProperty()
email: string;

Swagger получает:

  • типы;
  • обязательность;
  • ограничения.

Frontend может автоматически генерировать формы и проверки.


Синхронизация с Zod и JSON Schema

Во многих проектах:

  • class-validator используется на backend;
  • zod — на frontend.

Причина:

  • лучшая интеграция Zod с React;
  • статический вывод типов;
  • удобный functional-style API.

Для синхронизации применяются:

  • генерация JSON Schema;
  • codegen;
  • OpenAPI.

Ограничения class-validator на frontend

Размер бандла

reflect-metadata и декораторы увеличивают bundle size.

Особенно заметно в:

  • мобильных приложениях;
  • SPA;
  • микрофронтендах.

Runtime-валидация

class-validator работает во время выполнения.

Это означает:

  • дополнительную нагрузку;
  • отсутствие полной tree-shaking оптимизации;
  • использование metadata reflection.

Проблемы сериализации

Классы плохо сериализуются по сравнению с plain objects.

Например:

JSON.stringify(dto)

может вести себя неожиданно при наличии методов и getter/setter.


Практическая архитектура

Наиболее распространённая схема

Shared package

packages/shared

Содержит:

  • DTO;
  • enum;
  • константы;
  • простые валидаторы.

Backend

Использует DTO напрямую:

@Post()
create(
  @Body() dto: CreateUserDto,
) {}

Frontend

Использует DTO для:

  • форм;
  • pre-validation;
  • отображения ошибок;
  • генерации UI.

Рекомендации по синхронизации

Не дублировать правила

Плохо:

minLength: 8

в трёх разных местах.

Хорошо:

@MinLength(8)

в одном DTO.


Хранить сообщения централизованно

@MinLength(8, {
  message: "Минимум 8 символов",
})

Избегать сложной логики в DTO

DTO должны содержать:

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

Не должны содержать:

  • бизнес-логику;
  • запросы;
  • сервисы.

Отделять transport layer от domain layer

DTO:

CreateUserDto

не должен становиться сущностью домена:

UserEntity

Это разные уровни архитектуры.


Проверка совместимости DTO

В крупных проектах применяются:

  • contract tests;
  • snapshot validation;
  • schema comparison;
  • OpenAPI diff.

Это помогает обнаруживать breaking changes до деплоя.


Типичная схема потока данных

Frontend

Form
  ↓
DTO
  ↓
class-validator
  ↓
HTTP request

Backend

HTTP request
  ↓
ValidationPipe
  ↓
DTO
  ↓
Service
  ↓
Database

Ошибки синхронизации

Разные версии shared package

Frontend:

shared-dto@1.0.0

Backend:

shared-dto@2.0.0

Результат:

  • неожиданные ошибки;
  • несовместимые правила;
  • некорректная валидация.

Отсутствие transform

Без:

transform: true

валидация вложенных DTO часто ломается.


Отсутствие @Type

Без:

@Type(() => AddressDto)

не работает nested validation.


Валидация без plainToInstance

Неправильно:

validate(data)

Правильно:

validate(
  plainToInstance(Dto, data),
)

Проверка неизвестных полей

Без:

forbidNonWhitelisted: true

API может принимать неожиданные свойства.

Это создаёт:

  • проблемы безопасности;
  • риск mass assignment;
  • нарушения контрактов API.

Масштабирование системы валидации

В больших проектах полезно разделять:

dto/
validators/
transformers/
schemas/
contracts/

Это делает систему:

  • расширяемой;
  • предсказуемой;
  • пригодной для долгосрочной поддержки.