При разработке приложений с разделением на frontend и backend возникает типичная проблема: правила проверки данных дублируются в нескольких местах. Например:
Подобная схема быстро приводит к рассинхронизации. В одном месте поле обязательно, в другом — нет. На клиенте пароль допускает 8 символов, а сервер требует минимум 12. В результате:
class-validator позволяет централизовать правила
проверки данных через классы и декораторы. Особенно эффективно
библиотека работает вместе с:
class-transformer;NestJS;Типичный backend DTO:
export class CreateUserDto {
@IsEmail()
email: string;
@MinLength(8)
password: string;
}
Frontend-форма часто реализует проверки отдельно:
if (!email.includes("@")) {
showError();
}
if (password.length < 8) {
showError();
}
Такой подход приводит к расхождению логики.
Наиболее распространённая архитектура — вынесение 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";
Теперь:
npm install class-validator class-transformer
Для работы декораторов:
{
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
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;
}
class-validator работает только с экземплярами
классов.
Неправильно:
const data = {
email: "test@test.com",
};
await validate(data);
Правильно:
const dto = plainToInstance(RegisterDto, data);
await validate(dto);
Ошибки имеют древовидную структуру.
Пример:
[
{
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"]
}
Это позволяет:
Даже если frontend уже проверил данные, backend обязан валидировать их повторно.
Причины:
На сервере чаще всего используется ValidationPipe.
Подключение:
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
transform: true
Автоматически преобразует plain object в экземпляр класса.
Без этого декораторы могут работать некорректно.
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: true
Вместо удаления лишних полей выбрасывает ошибку.
Это особенно важно для:
Пример адаптера:
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,
};
}
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"],
});
export class UpdateUserDto {
@IsOptional()
@MinLength(8)
password?: string;
}
Или через группы:
export class UserDto {
@IsNotEmpty({
groups: ["create"],
})
name: string;
}
PATCH отличается от POST тем, что поля необязательны.
Проблема:
@MinLength(8)
password?: string;
Если поле отсутствует, validator всё равно может выполнить проверку.
Решение:
@IsOptional()
@MinLength(8)
password?: string;
Базовый DTO:
export class BaseUserDto {
@IsEmail()
email: string;
}
Наследование:
export class CreateUserDto
extends BaseUserDto {
@MinLength(8)
password: string;
}
Для update DTO:
export class UpdateUserDto
extends PartialType(CreateUserDto) {}
Все поля становятся optional автоматически.
Адрес пользователя:
export class AddressDto {
@IsString()
city: string;
@IsString()
street: string;
}
Основной DTO:
export class UserDto {
@ValidateNested()
@Type(() => AddressDto)
address: AddressDto;
}
Без @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[];
}
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:
Иначе общий пакет перестанет быть универсальным.
@ValidatorConstraint({ async: true })
export class UniqueEmailConstraint {
async validate(email: string) {
return !(await userExists(email));
}
}
Подобные проверки обычно выполняются только на сервере.
На практике полезно разделять:
Пример:
@IsEmail()
email: string;
На сервере дополнительно:
@Validate(UniqueEmailConstraint)
email: string;
Если frontend и backend обновляются независимо, DTO могут стать несовместимыми.
Например:
@IsString()
username: string;
Позже:
@Length(5, 20)
username: string;
Старый frontend может не учитывать новые ограничения.
shared-dto@1.2.0
Изменения правил валидации:
CreateUserV1Dto
CreateUserV2Dto
Подход особенно полезен для публичных API.
В NestJS:
@ApiProperty()
email: string;
Swagger получает:
Frontend может автоматически генерировать формы и проверки.
Во многих проектах:
class-validator используется на backend;zod — на frontend.Причина:
Для синхронизации применяются:
reflect-metadata и декораторы увеличивают bundle
size.
Особенно заметно в:
class-validator работает во время выполнения.
Это означает:
Классы плохо сериализуются по сравнению с plain objects.
Например:
JSON.stringify(dto)
может вести себя неожиданно при наличии методов и getter/setter.
packages/shared
Содержит:
Использует DTO напрямую:
@Post()
create(
@Body() dto: CreateUserDto,
) {}
Использует DTO для:
Плохо:
minLength: 8
в трёх разных местах.
Хорошо:
@MinLength(8)
в одном DTO.
@MinLength(8, {
message: "Минимум 8 символов",
})
DTO должны содержать:
Не должны содержать:
DTO:
CreateUserDto
не должен становиться сущностью домена:
UserEntity
Это разные уровни архитектуры.
В крупных проектах применяются:
Это помогает обнаруживать breaking changes до деплоя.
Form
↓
DTO
↓
class-validator
↓
HTTP request
HTTP request
↓
ValidationPipe
↓
DTO
↓
Service
↓
Database
Frontend:
shared-dto@1.0.0
Backend:
shared-dto@2.0.0
Результат:
Без:
transform: true
валидация вложенных DTO часто ломается.
Без:
@Type(() => AddressDto)
не работает nested validation.
Неправильно:
validate(data)
Правильно:
validate(
plainToInstance(Dto, data),
)
Без:
forbidNonWhitelisted: true
API может принимать неожиданные свойства.
Это создаёт:
В больших проектах полезно разделять:
dto/
validators/
transformers/
schemas/
contracts/
Это делает систему: