Обработка ошибок в асинхронных валидаторах

Асинхронные валидаторы в class-validator используются в сценариях, где проверка свойства требует обращения к внешним источникам: базе данных, HTTP-сервисам, кэшу или файловой системе. В отличие от синхронных проверок, асинхронные ограничения возвращают Promise, что влияет на весь жизненный цикл валидации объекта.

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


Контракт асинхронного валидатора

Асинхронный валидатор в class-validator определяется через функцию, возвращающую Promise<boolean> или значение, приводимое к промису. В типичном виде кастомное ограничение реализует интерфейс ValidatorConstraintInterface.

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

  • true — значение прошло валидацию
  • false — значение не соответствует правилу
  • Promise.reject() или выброшенное исключение — критическая ошибка выполнения проверки

Поведение Promise в механизме валидации

Внутренний механизм class-validator агрегирует все валидаторы через Promise.all. Это означает, что:

  • все асинхронные проверки запускаются параллельно
  • порядок выполнения не гарантируется
  • итоговый результат формируется после завершения всех промисов

Такая модель делает невозможным ранний выход при первой ошибке, если явно не используется стратегия остановки выполнения (stopAtFirstError), которая влияет только на уровень обработки результатов, но не отменяет уже запущенные промисы.


Ошибки через reject и throw

Асинхронный валидатор может сигнализировать о проблеме двумя основными способами:

Возврат false

Наиболее безопасный вариант. Ошибка фиксируется как обычное нарушение ограничения.

throw new Error()

Исключение перехватывается системой и преобразуется в ValidationError. При этом текст ошибки становится частью constraints.

Promise.reject()

Аналогично throw, но через механизм промисов. Используется реже, но приводит к тому же результату.

Важно, что class-validator не различает семантически throw и reject — оба пути приводят к формированию ошибки уровня ограничения.


validate и validateOrReject в контексте асинхронности

Поведение функций верхнего уровня определяет способ обработки ошибок.

validate

Функция validate() возвращает массив ValidationError[]. При асинхронных валидаторах:

  • все ошибки собираются в структуру
  • промисы дожидаются завершения
  • результат всегда резолвится, даже при наличии ошибок

Структура ошибки включает:

  • property
  • constraints
  • children
  • value

validateOrReject

Функция validateOrReject() изменяет модель обработки:

  • при наличии ошибок возвращается Promise.reject(ValidationError[])
  • при успехе возвращается void
  • удобна для использования в сервисных слоях, где ошибки трактуются как исключения

Асинхронные валидаторы в этом режиме не прерывают выполнение заранее — они также дожидаются завершения всех промисов перед формированием rejection.


Структура ValidationError при асинхронных ошибках

Асинхронная ошибка не отличается по структуре от синхронной. Отличие заключается только в источнике:

{
  property: 'email',
  value: 'test@example.com',
  constraints: {
    isEmailTaken: 'Email already exists'
  }
}

Если внутри асинхронного валидатора происходит исключение без явного текста, class-validator формирует сообщение на основе Error.message. При отсутствии сообщения используется стандартная строка.


Агрегация ошибок при множественных асинхронных проверках

Когда одно свойство имеет несколько асинхронных ограничений, все они выполняются параллельно. Итоговая структура constraints содержит все нарушенные правила.

Пример поведения:

  • проверка уникальности в базе данных
  • проверка внешнего API
  • проверка бизнес-логики

Все три проверки могут завершиться независимо, и каждая добавит свою запись в constraints, если возвращает false или выбрасывает ошибку.


Работа с внешними запросами внутри асинхронных валидаторов

Асинхронные валидаторы часто зависят от HTTP-запросов или запросов к БД. Это формирует несколько важных технических аспектов:

  • отсутствие контроля над задержкой ответа
  • риск увеличения общего времени валидации
  • потенциальная блокировка event loop при неправильной реализации

При использовании ORM или HTTP-клиентов важно возвращать именно Promise, а не смешивать синхронные побочные эффекты с асинхронным результатом.


Таймауты и гонки состояний

class-validator не управляет таймаутами асинхронных операций. Это означает, что:

  • зависшие промисы блокируют завершение валидации
  • внешние сервисы могут увеличить latency всего запроса
  • гонки данных возможны при параллельных запросах к одному ресурсу

Типичная проблема возникает при проверке уникальности:

  • два запроса проходят валидацию одновременно
  • оба видят отсутствие записи
  • оба проходят проверку
  • возникает конфликт на уровне базы данных

Такая ситуация не решается средствами class-validator и требует транзакционной логики или уникальных индексов на уровне хранения данных.


Контекст выполнения и инъекции зависимостей

Асинхронные валидаторы часто используют сервисы, внедряемые через DI (например, TypeDI или NestJS интеграции). В этом случае важно учитывать:

  • экземпляр валидатора может быть переиспользован
  • состояние внутри валидатора должно быть изолированным
  • асинхронные вызовы не должны изменять shared state

Нарушение этих условий приводит к неконсистентным результатам валидации при параллельных запросах.


Кастомный асинхронный валидатор

Типичная реализация асинхронного ограничения включает проверку внешнего источника данных:

@ValidatorConstraint({ async: true })
class IsEmailUniqueConstraint implements ValidatorConstraintInterface {
  async validate(email: string): Promise<boolean> {
    const user = await userRepository.findOne({ where: { email } });
    return !user;
  }

  defaultMessage(): string {
    return 'Email уже используется';
  }
}

В этом случае:

  • validate возвращает Promise<boolean>
  • false автоматически превращается в ошибку
  • defaultMessage применяется при отсутствии кастомного сообщения

Генерация ошибок через исключения внутри async validate

Асинхронный валидатор может выбрасывать исключение для передачи более сложного контекста ошибки:

async validate(email: string): Promise<boolean> {
  const response = await api.checkEmail(email);

  if (response.status === 429) {
    throw new Error('Сервис проверки перегружен');
  }

  return response.available;
}

Такой подход приводит к тому, что ошибка становится частью constraints, но теряет структурированность, если не обрабатывать её явно через ValidationError.


Нормализация асинхронных ошибок

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

  • boolean-результаты
  • строки сообщений
  • исключения внешних сервисов

Структура ValidationError.constraints позволяет агрегировать их в единый формат, однако без дополнительной логики возможна потеря семантики ошибок.

Распространённый подход — приведение всех ошибок к строковому описанию внутри defaultMessage или через обёртку над валидатором, чтобы исключить неоднозначность источников отказа.


Особенности поведения при вложенной валидации

При использовании validateNested или декораторов @ValidateNested() асинхронные валидаторы внутри вложенных объектов выполняются по той же модели Promise-агрегации. Это означает:

  • вложенные объекты валидируются параллельно
  • ошибки поднимаются в родительскую структуру children
  • асинхронные ошибки не прерывают проверку соседних веток

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