Асинхронная валидация не выполняется

В class-validator асинхронные правила часто «не срабатывают» не из-за самой библиотеки, а из-за особенностей её модели выполнения: асинхронная валидация существует отдельно от синхронного потока и требует явного корректного запуска через Promise-ориентированный API.

Основное отличие заключается в том, что библиотека не выполняет автоматическое ожидание асинхронных ограничений внутри синхронных функций. Любая асинхронная проверка должна быть частью цепочки, которая возвращает Promise<ValidationError[]>, иначе результат либо игнорируется, либо теряется в процессе выполнения.


Вся логика class-validator делится на два уровня:

  • синхронная: validateSync()
  • асинхронная: validate()

Ключевой момент — асинхронные ограничения работают только при использовании validate().

import { validate } from 'class-validator';

const errors = await validate(dto);

Если использовать:

import { validateSync } from 'class-validator';

const errors = validateSync(dto);

любые асинхронные правила будут полностью проигнорированы, потому что validateSync физически не поддерживает Promise.


Типичная причина: отсутствие await

Наиболее частая ситуация — вызов validate() без ожидания результата.

validate(dto); // ошибка: результат не используется

Фактически функция возвращает Promise, но дальнейшая логика продолжает выполняться до завершения проверок. Это создаёт ощущение, что асинхронная валидация «не работает».

Корректный вариант:

const errors = await validate(dto);

или:

validate(dto).then(errors => {
  // обработка
});

Асинхронные кастомные валидаторы

Асинхронность в class-validator чаще всего реализуется через ValidatorConstraint с async validate().

import {
  ValidatorConstraint,
  ValidatorConstraintInterface,
} from 'class-validator';

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

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

Критические условия, без которых асинхронная логика не выполнится:

  • async: true в декораторе ValidatorConstraint
  • метод validate возвращает Promise<boolean>
  • constraint зарегистрирован и используется в декораторе поля

Ошибка: забытый async в constraint

Если убрать async: true, поведение становится синхронным:

@ValidatorConstraint()
export class BrokenConstraint {
  async validate(value: string) {
    return await checkSomething(value);
  }
}

В этом случае class-validator может интерпретировать Promise как truthy-значение, что приводит к некорректному результату: проверка всегда проходит.


Проблема с return в validate()

Асинхронная функция обязана возвращать результат явно. Частая ошибка — выполнение запроса без return:

async validate(email: string): Promise<boolean> {
  database.users.findByEmail(email); // нет return
}

Фактически возвращается undefined, что интерпретируется как false или приводит к неожиданному поведению в зависимости от контекста.

Правильно:

async validate(email: string): Promise<boolean> {
  const user = await database.users.findByEmail(email);
  return !user;
}

NestJS и скрытая потеря async-валидации

В экосистеме NestJS часто возникает ситуация, когда асинхронная валидация не выполняется из-за пайпов.

Если используется ValidationPipe:

app.useGlobalPipes(new ValidationPipe());

и при этом отключён transform или неправильно настроен pipe, результат может быть обработан до завершения асинхронных проверок.

Особенно проблемные случаи:

  • ручной вызов DTO без await validate()
  • использование class-transformer без корректного преобразования типов
  • возврат DTO в сервисе без ожидания результата валидации

Потеря Promise в промежуточной логике

Распространённый анти-паттерн:

function createUser(dto) {
  const errors = validate(dto);

  if (errors.length) {
    throw new Error('validation failed');
  }

  return repository.save(dto);
}

Здесь errors — Promise, а не массив. Условие всегда истинно или всегда ложное в зависимости от приведения типов.

Корректный вариант:

async function createUser(dto) {
  const errors = await validate(dto);

  if (errors.length) {
    throw new Error('validation failed');
  }

  return repository.save(dto);
}

Валидация массивов и вложенных объектов

Асинхронные правила внутри вложенных DTO требуют включения рекурсивной валидации:

import { Type } from 'class-transformer';
import { ValidateNested } from 'class-validator';

class ProfileDto {
  @ValidateNested()
  @Type(() => UserDto)
  user: UserDto;
}

Если @ValidateNested() отсутствует, вложенные асинхронные проверки не выполняются вовсе, создавая эффект «частичной валидации».


Ограничения run-time выполнения

Асинхронная валидация в class-validator выполняется последовательно в рамках одной цепочки Promise. Это означает:

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

При большом количестве async constraints это может создавать задержки, которые ошибочно воспринимаются как «валидация зависла».


Проблемы с return false / undefined

Некорректные типы возврата часто ломают асинхронную логику:

async validate(value: string) {
  if (!value) return; // undefined
}

Class-validator ожидает строго boolean или Promise<boolean>. Любое отклонение интерпретируется как ошибка логики валидации.

Правильно:

async validate(value: string): Promise<boolean> {
  if (!value) return false;
  return true;
}

Конфликты с синхронными декораторами

Если на одном поле смешиваются синхронные и асинхронные правила, порядок выполнения может создавать иллюзию отсутствия async-валидации.

@IsNotEmpty()
@Validate(IsEmailUniqueConstraint)
email: string;

Если IsNotEmpty проваливается первым, асинхронный constraint может вообще не вызываться из-за short-circuit поведения.


Отладка выполнения async constraints

При диагностике важно учитывать, что class-validator не выводит внутренний поток выполнения. Поэтому полезно добавлять явные точки наблюдения:

async validate(value: string): Promise<boolean> {
  console.log('start async check');

  const result = await externalService.check(value);

  console.log('end async check');

  return result;
}

Если лог не появляется — проблема не в async-логике, а в том, что constraint не был зарегистрирован или не был вызван.


Итоговые причины «невыполнения» асинхронной валидации

  • использование validateSync() вместо validate()
  • отсутствие await при вызове validate
  • отсутствие async: true в кастомных constraints
  • отсутствие return в async-методах
  • использование undefined вместо boolean
  • пропуск @ValidateNested() для вложенных DTO
  • short-circuit из-за синхронных валидаторов
  • неправильная интеграция с NestJS pipes