Циклическая зависимость возникает, когда два или более класса с
валидацией ссылаются друг на друга напрямую или через цепочку импортов.
В контексте class-validator это особенно критично,
поскольку метаданные декораторов вычисляются в момент загрузки модулей,
а не во время выполнения бизнес-логики.
Типичная ситуация проявляется в DTO-структурах или сущностях, где
одна модель включает другую, а та, в свою очередь, ссылается обратно.
При отсутствии корректной организации импортов и отложенного разрешения
типов это приводит к тому, что один из классов оказывается
undefined в момент построения метаданных.
class-validator работает поверх системы декораторов
TypeScript и использует рефлексию (reflect-metadata) для
хранения информации о полях и их правилах валидации.
При выполнении кода декораторы типа @ValidateNested,
@Type, @IsArray регистрируют метаданные сразу
при импорте модуля:
import { ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
Если класс ещё не инициализирован из-за циклического импорта,
значение, переданное в декоратор, становится undefined.
Пример классической циклической зависимости:
// user.dto.ts
import { ProfileDto } from './profile.dto';
export class UserDto {
@ValidateNested()
@Type(() => ProfileDto)
profile: ProfileDto;
}
// profile.dto.ts
import { UserDto } from './user.dto';
export class ProfileDto {
@ValidateNested()
@Type(() => UserDto)
user: UserDto;
}
На этапе загрузки модулей оба файла начинают импортировать друг друга, и один из классов оказывается неинициализированным.
Наиболее распространённые проявления:
TypeError: Cannot read properties of undefinedundefined is not a constructor@ValidateNested() не срабатываетclass-transformer не преобразует вложенные объектыИногда ошибка не проявляется сразу и возникает только при выполнении валидации конкретного поля, что усложняет диагностику.
Основной механизм обхода проблемы заключается в том, что
class-validator и class-transformer допускают
передачу типа не напрямую, а через функцию:
@Type(() => ProfileDto)
Функция вызывается позже, когда все модули уже загружены, и позволяет
избежать обращения к undefined на этапе импорта.
Корректный вариант при циклической зависимости:
// user.dto.ts
import { ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class UserDto {
@ValidateNested()
@Type(() => import('./profile.dto').ProfileDto)
profile: any;
}
Однако прямой import() внутри Type не
всегда удобен, поэтому чаще используется промежуточная
функция-резолвер.
Наиболее устойчивый подход — перенос ссылки на класс в функцию, возвращающую его значение:
// user.dto.ts
import { ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class UserDto {
@ValidateNested()
@Type(() => ProfileDto)
profile: ProfileDto;
}
function ProfileDto(): any {
return require('./profile.dto').ProfileDto;
}
Более корректный вариант с учётом TypeScript:
@Type(() => require('./profile.dto').ProfileDto)
Такой подход откладывает разрешение модуля до момента выполнения функции, когда циклическая загрузка уже завершена.
Наиболее распространённый паттерн — стрелочная функция, возвращающая класс:
@Type(() => ProfileDto)
При циклических зависимостях важно, чтобы сам класс был объявлен до использования функции:
export class UserDto {
@ValidateNested()
@Type(() => ProfileDto)
profile: ProfileDto;
}
export class ProfileDto {
@ValidateNested()
@Type(() => UserDto)
user: UserDto;
}
В этом случае ключевым является не импорт, а порядок инициализации модулей. Однако это работает только при отсутствии строгого циклического импорта на уровне файлов.
Архитектурный способ устранения проблемы — устранение взаимной зависимости на уровне доменной модели.
Часто циклы возникают из-за неправильного моделирования:
UserDto ↔︎ ProfileDtoOrderDto ↔︎ ProductDtoCommentDto ↔︎ UserDtoВместо прямых ссылок используется промежуточная модель или упрощённая структура:
export class ProfileDto {
userId: number;
}
или
export class UserDto {
profile: ProfileSummaryDto;
}
где ProfileSummaryDto не содержит обратной ссылки.
Ещё один способ устранения циклов — перенос импортов внутрь функций или методов:
export class UserDto {
@ValidateNested()
@Type(() => getProfileDto())
profile: any;
}
function getProfileDto() {
return require('./profile.dto').ProfileDto;
}
Такой подход полностью разрывает граф зависимостей на этапе загрузки модулей.
Файлы-агрегаторы (index.ts) часто усиливают
проблему:
export * from './user.dto';
export * from './profile.dto';
Если оба класса импортируются через общий индекс, создаётся дополнительный уровень циклической зависимости, который сложно отследить.
Пример проблемного импорта:
import { UserDto } from '../dtos';
import { ProfileDto } from '../dtos';
Это может привести к скрытому циклу даже при отсутствии прямых импортов между файлами.
Декоратор @ValidateNested() зависит от корректного типа,
который должен быть доступен в момент построения метаданных.
@ValidateNested()
@Type(() => ProfileDto)
profile: ProfileDto;
Если ProfileDto в этот момент не определён, метаданные
сохраняются некорректно, и вложенная валидация полностью
игнорируется.
Некоторые модели естественно являются рекурсивными:
Пример:
export class CategoryDto {
@ValidateNested({ each: true })
@Type(() => CategoryDto)
children: CategoryDto[];
}
В этом случае циклическая зависимость является не ошибкой архитектуры, а необходимостью. Однако важно различать:
В сложных проектах часто применяется разделение:
Циклы возникают только во втором случае.
Решение:
// types.ts
export interface IUser {
profile: IProfile;
}
// user.dto.ts
export class UserDto {
@ValidateNested()
@Type(() => ProfileDto)
profile: ProfileDto;
}
Сборщики вроде Webpack или Vite могут по-разному обрабатывать циклические зависимости:
Это приводит к нестабильным ошибкам, которые могут проявляться только в production-сборке.
Особенно критично:
Выявление циклической зависимости требует анализа графа импортов. Основные признаки:
undefined в
runtimeЧасто помогает временное логирование:
console.log(ProfileDto);
Если вывод undefined, цикл подтверждён.
Основные устойчивые стратегии: