Опирается на правила:
R-VLD-MSG-1,R-VLD-MSG-2,R-VLD-MSG-3иR-VLD-MSG-X1…R-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-X3 | defaultMessage() в констрейнте; переопределяем только при конкретной причине |
| Дефолтные английские сообщения class-validator в ответе | R-VLD-MSG-1 | Явный русский message на каждом декораторе |
Хардкод числа в тексте вместо $constraint1/$constraint2 | R-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.