Интернационализация сообщений

Библиотека class-validator предоставляет механизм валидации объектов через декораторы и функциональные правила, а система сообщений об ошибках в ней изначально ориентирована на строковые шаблоны. При переходе к многоязычным приложениям основная сложность возникает не в самой валидации, а в привязке текстов ошибок к локалям без нарушения архитектуры доменной модели.

Репозиторий: class-validator GitHub

Каждое правило валидации в class-validator при провале генерирует объект ошибки, содержащий ключевые поля:

  • property — имя поля
  • constraints — набор сообщений
  • value — переданное значение
  • target — объект-носитель

constraints является центральной частью, поскольку именно здесь формируются строки сообщений.

Пример стандартного поведения:

import { IsEmail } from "class-validator";

class User {
  @IsEmail()
  email: string;
}

При ошибке:

{
  "property": "email",
  "constraints": {
    "isEmail": "email must be an email"
  }
}

Строка "email must be an email" формируется либо библиотекой, либо пользовательским сообщением.

Параметр message как точка интернационализации

Каждый декоратор поддерживает опцию message, которая позволяет переопределить текст ошибки:

import { IsEmail } from "class-validator";

class User {
  @IsEmail({}, { message: "Invalid email format" })
  email: string;
}

Однако ключевой момент для интернационализации заключается в том, что message может быть функцией.

Функция message

@IsEmail({}, {
  message: (args) => {
    return `Field ${args.property} is not valid`;
  }
})
email: string;

Аргумент args содержит:

  • property
  • value
  • constraints
  • targetName

Это открывает возможность динамической подстановки переводов.

Базовый подход к интернационализации через функцию

Самый прямой способ интернационализации — использование функции, возвращающей перевод из словаря.

const messages = {
  en: {
    emailInvalid: "Invalid email address",
  },
  ru: {
    emailInvalid: "Некорректный email адрес",
  }
};

let locale = "ru";

function t(key: string) {
  return messages[locale][key];
}

Использование в декораторе:

@IsEmail({}, {
  message: () => t("emailInvalid")
})
email: string;

Этот подход имеет ограничение: локаль фиксируется в момент объявления класса, а не в момент выполнения валидации.

Динамическая локаль через контекст выполнения

Для реальной интернационализации локаль должна определяться во время запроса или выполнения логики.

Типовой подход:

class I18nService {
  constructor(private locale: string) {}

  t(key: string) {
    const dict = {
      ru: { emailInvalid: "Некорректный email" },
      en: { emailInvalid: "Invalid email" }
    };

    return dict[this.locale][key];
  }
}

Интеграция через фабрику сообщений:

function createValidators(i18n: I18nService) {
  return {
    emailMessage: () => i18n.t("emailInvalid")
  };
}

Использование:

@IsEmail({}, {
  message: (args) => validators.emailMessage()
})
email: string;

Проблема контекста и глобального состояния

class-validator выполняет валидацию через функцию validate() или validateOrReject():

import { validate } from "class-validator";

const user = new User();
user.email = "invalid";

const errors = await validate(user);

Ключевая особенность: библиотека не хранит контекст запроса, поэтому:

  • нельзя автоматически определить язык пользователя
  • нельзя использовать request-scoped зависимости напрямую
  • необходимо прокидывать зависимости вручную или через фабрики

Использование i18n библиотек

На практике интернационализация почти всегда строится поверх внешних решений:

  • i18next
  • formatjs / intl-messageformat
  • nestjs-i18n (в экосистеме NestJS)

Пример с i18next:

import i18next from "i18next";

i18next.init({
  lng: "ru",
  resources: {
    ru: {
      translation: {
        email_invalid: "Некорректный email"
      }
    }
  }
});

Использование:

@IsEmail({}, {
  message: () => i18next.t("email_invalid")
})
email: string;

Разделение кода ошибок и текстов

Более масштабируемый подход — отказ от строк в пользу кодов ошибок.

constraints: {
  isEmail: "EMAIL_INVALID"
}

Затем на уровне API выполняется трансформация:

const errorMap = {
  EMAIL_INVALID: "Некорректный email",
  en: {
    EMAIL_INVALID: "Invalid email"
  }
};

Такой подход позволяет:

  • отделить доменную логику от UI
  • централизовать переводы
  • упростить кеширование

Использование transform слоя

Типовая архитектура:

  1. class-validator возвращает raw errors
  2. transform service преобразует ошибки
  3. i18n слой подставляет строки

Пример трансформации:

function mapErrors(errors, locale) {
  return errors.map(err => {
    const constraints = err.constraints;

    const translated = Object.fromEntries(
      Object.entries(constraints).map(([key]) => {
        return [key, translate(key, locale)];
      })
    );

    return {
      property: err.property,
      messages: translated
    };
  });
}

Ленивая интернационализация через factory validators

В сложных системах создаётся фабрика валидаторов:

export function createValidationMessages(t: (key: string) => string) {
  return {
    isEmail: () => t("validation.email"),
    isRequired: () => t("validation.required")
  };
}

И затем:

@IsNotEmpty({
  message: () => messages.isRequired()
})
email: string;

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

  • централизовать переводы
  • тестировать локали отдельно
  • избегать глобального состояния

Проблема повторного использования классов

Ограничение class-validator заключается в том, что декораторы статичны. Это означает:

  • нельзя менять язык без пересоздания логики message
  • нельзя легко переопределить сообщения для разных клиентов

Решения:

  • генерация классов через фабрики
  • динамическое создание DTO
  • использование runtime metadata

Runtime генерация DTO

function createUserDto(t) {
  class UserDto {
    @IsEmail({}, {
      message: () => t("email_invalid")
    })
    email;
  }

  return UserDto;
}

Использование:

const UserDtoRu = createUserDto((k) => ruDict[k]);
const UserDtoEn = createUserDto((k) => enDict[k]);

Интеграция с middleware уровнем

В серверных приложениях локаль часто определяется middleware:

function localeMiddleware(req, res, next) {
  req.locale = req.headers["accept-language"] || "en";
  next();
}

Далее сервис переводов использует req.locale, но важно не пытаться передать req в декораторы напрямую — это нарушает изоляцию слоя валидации.

Ошибки и нормализация constraints

Внутренняя структура constraints может содержать несколько ключей:

{
  "isEmail": "email must be an email",
  "minLength": "email is too short"
}

При интернационализации важно нормализовать ключи:

  • isEmailvalidation.email.invalid
  • minLengthvalidation.string.min

Это позволяет строить иерархические словари переводов.

Типизированные ключи ошибок

Для предотвращения расхождений используется типизация:

type ValidationKey =
  | "validation.email.invalid"
  | "validation.required";

Это уменьшает риск несоответствия между кодом и переводами.

Связь с class-transformer

В связке с class-transformer часто возникает необходимость:

  • сначала трансформировать данные
  • затем валидировать
  • затем локализовать ошибки

Порядок критичен, поскольку ошибки зависят от финального вида объекта.

Вывод архитектурных паттернов

Интернационализация в class-validator строится вокруг трёх моделей:

  • функции message как runtime-слоя
  • кодов ошибок вместо строк
  • внешнего i18n сервиса

Каждая модель решает отдельный класс проблем: статичность декораторов, отсутствие контекста запроса и необходимость централизованного управления переводами.