Опирается на правила: R-VLD-MSG-1, R-VLD-MSG-2, R-VLD-MSG-3 и R-VLD-MSG-X1R-VLD-MSG-X3 из Validation Style Guide → раздел 8. Сообщения и i18n.

Важно знать

  • message — на русском. Строка из опции message декоратора class-validator попадёт в violations[].message и дойдёт до пользователя на UI.
  • Для пользователя, не для разработчика. «Сумма должна быть положительной», не «amount must be greater than 0».
  • Интерполяция через $constraint1, $property, $value. class-validator подставляет значения в момент валидации; не хардкодим числа в тексте.
  • Default message глобального ValidationPipe — английский. Без переопределения message он попадёт в ответ.
  • i18n — через nestjs-i18n + i18nValidationMessage: один ключ в каталоге, не копипаст текста по полям.
  • Технические термины (property, value, constraint, имена классов) — антипаттерн. Пользователь не знает, что такое property.
  • Английский в message — антипаттерн: текст попадёт в UI без возможности перехвата на границе.
  • defaultMessage() в @ValidatorConstraint — одно место правды для custom-constraint; переопределяем в декораторе только при конкретной бизнес-причине.

message в class-validator-декораторе — единственный текст, который при невалидном вводе доходит до пользователя через violations[].message в ответе 400. Это product copy, а не технический лог.

Default — на русском, без подмены фронтом

R-VLD-MSG-1: текст пишется на языке UI (в проекте — русский).

export class CreateCustomerRequest {
  @IsNotEmpty({ message: 'Имя обязательно' })
  @Length(1, 100, { message: 'Имя от 1 до 100 символов' })
  firstName: string;

  @IsNotEmpty({ message: 'Телефон обязателен' })
  @RussianPhone()
  phone: string;

  @IsISO8601({}, { message: 'Дата рождения в формате ISO 8601' })
  @MaxDate(new Date(), { message: 'Дата рождения должна быть в прошлом' })
  birthDate: string;
}

При пустом firstName в violations придёт:

{
  "type": "/errors/validation-error",
  "status": 400,
  "violations": [
    { "field": "firstName", "message": "Имя обязательно" }
  ]
}

Фронт показывает «Имя обязательно» рядом с полем. Без маппинга, без перевода на стороне UI.

R-VLD-MSG-X1 — английский в message — антипаттерн:

// ПЛОХО
@IsNotEmpty({ message: 'First name is required' })
@Length(1, 100, { message: 'First name must be at most 100 characters' })
firstName: string;

Что произойдёт: текст попадёт в UI как есть. Перевод на стороне фронта потребует маппинга строки → строки — и он постоянно отстаёт от backend. Backend — единственное место правды для текста ошибок.

Интерполяция через placeholders

R-VLD-MSG-2: динамические значения подставляются через placeholder-ы class-validator.

export class CreateOrderRequest {
  @IsNotEmpty({ message: 'Название заказа обязательно' })
  @Length(1, 200, { message: 'Название от $constraint1 до $constraint2 символов' })
  name: string;

  @IsInt({ message: 'Количество должно быть целым числом' })
  @Min(1, { message: 'Количество не менее $constraint1' })
  @Max(9999, { message: 'Количество не более $constraint1' })
  quantity: number;

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

Стандартные placeholder-ы class-validator:

PlaceholderЧто подставляется
$valueФактическое значение поля
$propertyИмя свойства DTO (техническое)
$constraint1, $constraint2, $constraint3Аргументы ограничения (min, max и т.д.)
$targetИмя класса DTO (техническое)

$property и $target — технические имена; в message их не используем (R-VLD-MSG-X2). $value применяем только когда значение информативно для пользователя (например, введённый код). Regex в тексте сообщения не полезен пользователю — вместо $constraint1 для @Matches пишем human-readable описание формата.

При quantity = 0 ответ:

{ "field": "quantity", "message": "Количество не менее 1" }

Default message в custom constraint

R-VLD-MSG-3: для custom constraint текст задаётся один раз в defaultMessage().

// common/validation/russian-phone.ts
@ValidatorConstraint({ name: 'russianPhone' })
export class RussianPhoneConstraint implements ValidatorConstraintInterface {
  validate(value: unknown): boolean {
    if (value === null || value === undefined) return true;
    return typeof value === 'string' && /^\+7\d{10}$/.test(value);
  }

  defaultMessage(): string {
    return 'Номер должен быть в формате +7XXXXXXXXXX';
  }
}

export function RussianPhone(options?: ValidationOptions) {
  return (object: object, propertyName: string) =>
    registerDecorator({
      target: object.constructor,
      propertyName,
      options,
      validator: RussianPhoneConstraint,
    });
}

Использование без переопределения — default message применяется автоматически:

export class CreateCustomerRequest {
  @IsNotEmpty({ message: 'Телефон обязателен' })
  @RussianPhone()
  phone: string;
}

Если конкретное поле требует другого текста (например, телефон представителя):

@IsNotEmpty({ message: 'Телефон представителя обязателен' })
@RussianPhone({ message: 'Телефон представителя в формате +7XXXXXXXXXX' })
representativePhone: string;

R-VLD-MSG-X3 — переопределение без бизнес-причины:

// ПЛОХО — совпадает с defaultMessage(), дублируется на двух полях
@RussianPhone({ message: 'Номер должен быть в формате +7XXXXXXXXXX' })
phone: string;

@RussianPhone({ message: 'Номер должен быть в формате +7XXXXXXXXXX' })
alternatePhone: string;

Два поля, один текст, совпадающий с default — достаточно @RussianPhone().

Технические термины — не для пользователя

R-VLD-MSG-X2: default-сообщения class-validator содержат технические термины — их не пропускаем в ответ.

// ПЛОХО — дефолтные английские сообщения class-validator
@IsNotEmpty()           // → "firstName should not be empty"
@IsUUID()               // → "customerId must be a UUID"
@IsInt()                // → "quantity must be an integer number"
// ХОРОШО — явный русский message
@IsNotEmpty({ message: 'Имя обязательно' })
firstName: string;

@IsUUID('4', { message: 'Идентификатор клиента должен быть UUID' })
customerId: string;

@IsInt({ message: 'Количество должно быть целым числом' })
quantity: number;

Что избегаем в тексте:

  • property, field, value как слова интерфейса — пользователь их не понимает.
  • Имена TypeScript-классов и типов: string, number, DTO.
  • Технический жаргон: «must satisfy constraint», «failed validation», «doesn't match pattern».
  • should not be empty — заменяем на «обязательно» или конкретнее.

Тон — спокойный, описывающий правило:

  • ✓ «Сумма должна быть положительной»
  • ✗ «Вы ввели неверную сумму»
  • ✗ «Ошибка в поле сумма»

i18n через nestjs-i18n

Если сервис мультиязычный, используем nestjs-i18n + i18nValidationMessage вместо прямой строки.

Установка:

// app.module.ts
I18nModule.forRoot({
  fallbackLanguage: 'ru',
  loaderOptions: { path: join(__dirname, '/i18n/'), watch: true },
});

Каталог src/i18n/ru/validation.json:

{
  "order": {
    "name_required": "Название заказа обязательно",
    "name_length": "Название от {args.min} до {args.max} символов",
    "amount_positive": "Сумма должна быть положительной"
  },
  "product": {
    "sku_required": "Артикул обязателен",
    "sku_format": "Артикул в формате AAA-000000 (три буквы, дефис, шесть цифр)"
  }
}

Каталог src/i18n/en/validation.json:

{
  "order": {
    "name_required": "Order name is required",
    "name_length": "Name must be between {args.min} and {args.max} characters",
    "amount_positive": "Amount must be positive"
  },
  "product": {
    "sku_required": "SKU is required",
    "sku_format": "SKU format: AAA-000000 (three letters, dash, six digits)"
  }
}

DTO с i18nValidationMessage:

export class CreateOrderRequest {
  @IsNotEmpty({ message: i18nValidationMessage('validation.order.name_required') })
  @Length(1, 200, { message: i18nValidationMessage('validation.order.name_length') })
  name: string;

  @IsDecimal({}, { message: i18nValidationMessage('validation.order.amount_positive') })
  @Min(0.01, { message: i18nValidationMessage('validation.order.amount_positive') })
  amount: string;
}

ValidationPipe с i18n-поддержкой:

// main.ts
app.useGlobalPipes(
  new I18nValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
    exceptionFactory: (errs) => new InputValidationError(formatViolations(errs)),
  }),
);

Accept-Language: en в запросе — клиент получит английский текст. Accept-Language: ru — русский.

Для проекта только на русском nestjs-i18n — избыточность. Прямая строка в message — правильный выбор. Локализация нужна только при реальном втором языке.

Полный пример — CreateProductRequest

export class CreateProductRequest {
  @IsNotEmpty({ message: 'Название обязательно' })
  @Length(1, 200, { message: 'Название от $constraint1 до $constraint2 символов' })
  name: string;

  @IsNotEmpty({ message: 'Артикул обязателен' })
  @Matches(/^[A-Z]{3}-\d{6}$/, {
    message: 'Артикул в формате AAA-000000 (три буквы, дефис, шесть цифр)',
  })
  sku: string;

  @IsDecimal({}, { message: 'Цена должна быть числом' })
  @Min(0.01, { message: 'Цена не менее $constraint1' })
  @Max(999999.99, { message: 'Цена не более $constraint1' })
  price: string;

  @IsUUID('4', { message: 'Идентификатор категории должен быть UUID' })
  categoryId: string;

  @IsOptional()
  @Length(0, 5000, { message: 'Описание не более $constraint2 символов' })
  description?: string;
}

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

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

АнтипаттернПравилоЧто взамен
Английский в message ("must be positive")R-VLD-MSG-X1Русский: «должно быть положительным»
Технические термины (property, should not be empty, имена классов)R-VLD-MSG-X2Пользовательский язык без технического жаргона
Дублирование default message на каждом поле без причиныR-VLD-MSG-X3defaultMessage() в констрейнте; переопределяем только при конкретной причине
Дефолтные английские сообщения class-validator в ответеR-VLD-MSG-1Явный русский message на каждом декораторе
Хардкод числа в тексте вместо $constraint1/$constraint2R-VLD-MSG-2"не менее $constraint1" — подставится из декоратора
$property или $target в message для пользователяR-VLD-MSG-X2Человеческое название поля вместо технического (firstName)

Куда дальше

  • node/standard-constraints.md — какие placeholder-ы доступны у каких декораторов.
  • node/custom-constraints.md — где задаётся defaultMessage() для кастомных констрейнтов.
  • node/where-to-validate.md — где именно живут DTO с декораторами.
  • node/cross-field-validation.md — class-level декораторы и их сообщения.
  • node/validation-groups.md — сообщения при сценарных группах.
  • node/configuration-validation.md — ConfigModule.forRoot и message при падении конфига.
  • node/openapi-generated-dto.md — code-first и @nestjs/swagger.
  • Error Handling → ProblemDetails mapping — как violations встраиваются в общий ответ 400.