Опирается на правила: R-VLD-STD-1R-VLD-STD-5 и R-VLD-STD-X1R-VLD-STD-X3 из Validation Style Guide → раздел 2. Стандартные constraints.

Важно знать

  • Required по умолчанию. В class-validator поле без @IsOptional() считается обязательным — класс-трансформер выбросит ошибку, если значение отсутствует.
  • @IsNotEmpty — строка не пустая и не из пробелов. @IsDefined — не undefined. Не взаимозаменяемы.
  • @IsEmail вместо @Matches(emailRegex). Библиотека поддерживает RFC 5321/5322 и обновляется.
  • @IsUUID, @IsUrl, @IsISO8601 — форматы из коробки, не выдумывай regex.
  • Числа из query string: transform: true в ValidationPipe приводит строку к number, но нужен @IsInt() или @IsNumber() для проверки.
  • Деньги — не number. Плавающая точка теряет копейки. Принимать как string, конвертировать в Decimal.js/BigInt в маппере.
  • @IsOptional() — всегда первым декоратором на поле. Если значение undefined, остальные декораторы пропускаются.

class-validator покрывает 80% типичных валидаций входных DTO. Стандартные декораторы читаются без объяснений: @IsEmail() понятен любому, @Matches(/^[^@]+@[^@]+$/) требует открывать regex. Свой велосипед ломает эту общую базу. Раскрытие раздела 2 гайда.

Required и optional

R-VLD-STD-1: обязательность — через отсутствие @IsOptional().

export class CreateCustomerRequest {
  @IsUUID()
  id: string;                        // required

  @IsNotEmpty()
  @MaxLength(100)
  firstName: string;                 // required, непустое

  @IsOptional()
  @IsNotEmpty()
  @MaxLength(100)
  middleName?: string;               // optional — если пришло, непустое

  @IsEmail()
  email: string;                     // required

  @IsOptional()
  @IsUrl()
  website?: string;                  // optional url
}

Разница @IsNotEmpty vs @IsDefined:

ДекораторЧто проверяет
@IsDefined()value !== undefined && value !== null
@IsNotEmpty()строка: value.trim().length > 0; массив: length > 0; объект: Object.keys(value).length > 0
@IsOptional()пропустить остальные декораторы, если value === undefined или value === null

Для строковых полей — @IsNotEmpty(). Для object-ссылок (вложенный объект) — достаточно @IsNotEmpty() или @ValidateNested плюс @Type.

Размеры — @Length, @Min, @Max, @ArrayMinSize

R-VLD-STD-2: правильный декоратор по типу.

export class OrderItemRequest {
  @IsString()
  @IsNotEmpty()
  @Length(1, 64)
  sku: string;

  @IsInt()
  @Min(1)
  @Max(9999)
  quantity: number;

  @IsString()
  @Matches(/^\d+\.\d{2}$/)          // строка "100.00", конвертируется в Decimal в маппере
  unitPrice: string;
}

export class CreateOrderRequest {
  @IsUUID()
  customerId: string;

  @ValidateNested({ each: true })
  @Type(() => OrderItemRequest)
  @ArrayMinSize(1)
  @ArrayMaxSize(100)
  items: OrderItemRequest[];
}

Правила:

  • @Length(min, max) — длина строки. Не для чисел.
  • @Min(n) / @Max(n) — целочисленные и float-числа (number в TypeScript).
  • @ArrayMinSize(n) / @ArrayMaxSize(n) — размер массива. Аналог @Size на коллекции в Java.
  • @IsPositive(), @IsNegative() — короткая форма для знака. Эквивалент @Min(1) и @Max(-1).

Формат — @IsEmail, @IsUUID, @IsUrl, @IsISO8601

R-VLD-STD-3: форматные декораторы из коробки, не @Matches.

export class CustomerProfile {
  @IsEmail()
  @MaxLength(254)
  email: string;

  @IsUUID('4')
  orderId: string;

  @IsUrl({ protocols: ['https'], require_protocol: true })
  callbackUrl: string;

  @IsISO8601()
  scheduledAt: string;                // "2025-03-15T10:00:00Z"

  @IsPhoneNumber('RU')                // встроенный через libphonenumber
  phone: string;
}

@Matches(emailRegex) вместо @IsEmail()R-VLD-STD-X2, антипаттерн:

// ПЛОХО — велосипед
@Matches(/^[^@]+@[^@]+\.[^@]+$/)
email: string;

// ХОРОШО
@IsEmail()
email: string;

Самописный regex пропускает user@@host.com, user@host без домена первого уровня. @IsEmail() из class-validator использует validator.js с учётом RFC.

Для редких доменных форматов @Matches остаётся уместным:

@Matches(/^[A-Z]{3}-\d{6}$/, { message: 'Артикул в формате AAA-000000' })
articleNumber: string;

Если тот же формат встречается в 3+ полях проекта — это сигнал к custom constraint (см. Custom constraints).

Время — @IsISO8601, @MinDate, @MaxDate

R-VLD-STD-4: входные даты в DTO приходят строкой. Декоратор + @Type(() => Date) для рекурсивных проверок.

export class CreateContractRequest {
  @IsISO8601()
  validFrom: string;          // "2025-03-15" или "2025-03-15T00:00:00Z"

  @IsISO8601()
  validUntil: string;
}

Если нужны проверки относительно текущей даты — @Type(() => Date) + @MinDate/@MaxDate:

export class BookingRequest {
  @Type(() => Date)
  @MinDate(new Date())
  checkIn: Date;

  @Type(() => Date)
  @MinDate(new Date())
  checkOut: Date;
}

Правило «checkIn < checkOut» — cross-field constraint на классе (см. Cross-field validation).

Тип-зависимая валидация и числа из query string

R-VLD-STD-5: transform: true в ValidationPipe конвертирует query-строки в числа, но нужен декоратор-проверка.

// controller
@Get('orders')
findAll(@Query() query: OrdersFilterQuery) { ... }

// DTO для query params
export class OrdersFilterQuery {
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page?: number;                  // "1" → 1

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  limit?: number;                 // "20" → 20

  @IsOptional()
  @IsEnum(OrderStatus)
  status?: OrderStatus;
}

Без @Type(() => Number) query-параметр остаётся строкой в runtime — @IsInt() упадёт. @Type нужен явно, даже с transform: true, на @Query()-объектах.

Деньги — не number:

// ПЛОХО — число 0.1 + 0.2 ≠ 0.3
unitPrice: number;

// ХОРОШО — строка, конвертируем в Decimal в маппере
@IsString()
@Matches(/^\d+(\.\d{1,2})?$/, { message: 'Цена в формате 100.00' })
unitPrice: string;

@IsOptional — всегда первым

R-VLD-STD-X1: порядок декораторов важен — @IsOptional() должен быть до остальных.

// ХОРОШО
@IsOptional()
@IsEmail()
alternateEmail?: string;

// ПЛОХО — если value=undefined, @IsEmail() упадёт раньше @IsOptional()
@IsEmail()
@IsOptional()
alternateEmail?: string;

class-validator выполняет декораторы снизу вверх (как в TypeScript). @IsOptional() в конце (в коде) означает выполнится первым в рантайме. Всегда пишем его первым в коде для читаемости и располагаем так, чтобы он срабатывал до остальных.

Что запрещено

АнтипаттернПравилоЧто взамен
@Matches(emailRegex) вместо @IsEmail()R-VLD-STD-X2@IsEmail()
Composite-декоратор (@NotEmptyMaxLength50) поверх стандартныхR-VLD-STD-X3Два явных: @IsNotEmpty() @MaxLength(50)
Деньги как number в DTOR-VLD-STD-5string + @Matches(/^\d+\.\d{2}$/), Decimal в маппере
@IsInt() на query-params без @Type(() => Number)R-VLD-STD-5@Type(() => Number) @IsInt()
Дублирование required-проверки (@IsDefined + @IsNotEmpty)R-VLD-STD-X1Один подходящий декоратор

Куда дальше

  • Validation → раздел 2. Стандартные constraints — нормативные формулировки R-VLD-STD-*.
  • Custom constraints — когда стандартных не хватает.
  • Где валидировать — где декораторы применяются.
  • Cross-field validation — для правил между несколькими полями.
  • OpenAPI и DTO в NestJS — code-first: DTO-класс как единственный источник правды.