Почему Nest живёт на старых декораторах

Nest≈ 15 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Контейнер Nest создаёт контроллер по списку зависимостей, которого в коде нет: constructor(private readonly notes: NotesService) — и список готов. Написал его компилятор, потому что в tsconfig.json стоят два ключа, которых не бывает у проекта на стандартных декораторах. Эта статья — про цену этих двух ключей. Из неё станет ясно, почему Nest не может уйти на стандартные декораторы, даже когда те доберутся до языка; почему @Body() и в двенадцатой версии остаётся декоратором параметра; почему одни и те же файлы приложения отвечают 200 под одной командой и 500 под другой; и чем такой проект собирают.

Разделы темы:·

Старую систему декораторов включает один ключ в tsconfig.json — experimentalDecorators. Она намного старше стандарта: TypeScript выпустил её по ранней редакции предложения, которое с тех пор переписали целиком. Общего у двух систем почти ничего: другие подписи, другой состав, другой вывод компилятора. Настройки песочницы поменять нельзя, зато компилятор можно позвать прямо из примера и дать ему любые ключи — тем же способом, каким в главе о своём каркасе печатали вывод для стандартных декораторов.

Ниже контроллер Nest в том виде, в каком его пишут: пометка на классе, зависимость в конструкторе, маршрут на методе и @Param перед параметром. Компилятору отданы оба ключа старой системы. Прогноз: сколько вызовов __metadata окажется в выводе и что достанется декоратору параметра, для которого в стандарте нет места?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни статического блока, ни __esDecorate — вместо них одна функция __decorate и два её вызова: для метода и для класса. Порядок прежний, члены раньше класса, а дальше всё ново.

__param(0, Param("id")) — это и есть декоратор параметра. Обернуть параметр не во что, поэтому помощник превращает его в декоратор метода: тот позовёт Param("id") и передаст ему прототип, имя метода и номер параметра. Возвращать такому декоратору нечего, его работа — оставить пометку в таблице, откуда каркас потом возьмёт значение для аргумента.

Записей о типах три, и все у метода: design:type — Function, design:paramtypes — [String], design:returntype — void 0. Компилятор записал в выпущенный код те самые типы, которые только что стёр. У класса запись одна, design:paramtypes с типом параметра конструктора, и вокруг имени стоит охрана: typeof NotesService !== "undefined" && NotesService. В этом примере NotesService нигде не объявлен, и компилятор не уверен, доживёт ли имя до запуска; когда класс объявлен рядом, он пишет имя голым — это будет видно в прогоне, где тот же класс пройдёт через четыре компилятора.

Каждое из отличий старой системы в Nest на что-нибудь да потрачено.

Есть в старой системеВ стандартнойЧто на этом держится в Nest
декоратор параметра: прототип, имя метода, номернет вовсе@Body, @Param, @Query, @Inject — всё, что пишут в скобках метода и конструктора
design:paramtypes у классаметаданные есть, но это общий объект сведений, а не типысписок зависимостей конструктора: контейнеру не нужен ни отдельный список, ни @Inject у класса
design:type у полянет@ApiProperty() без аргументов: тип поля @nestjs/swagger берёт отсюда

Старая система не «недоделанная новая». Это другая модель: не «замени значение», а «оставь пометку о месте».

Раз метаданные так полезны, напрашивается вопрос: нельзя ли взять из старой системы их одни, а декораторы оставить стандартные? Ответ даёт сам компилятор. Ниже три запуска tsc над одним и тем же файлом, разница только в ключах. Прогноз: в каком из трёх запусков будет напечатан вызов Body(), а в каком не будет ни одной диагностики?

прогон node 26.5.0 · 22 сентября 2026
один файл с @Body() в параметре и три набора ключей компилятора
// декоратор параметра — тот самый @Body() из контроллера Nest;
// подпись старая: класс, имя метода, номер параметра
function Body() {
  return function (target: object, method: string | symbol | undefined, index: number) {
    console.log(`Body() пометил параметр ${index} метода ${String(method)}`);
  };
}

class NotesController {
  create(@Body() dto: unknown) {
    return dto;
  }
}

new NotesController();
node diag.mjs

Строка «Body() пометил параметр» не напечаталась ни разу: tsc файл проверяет, а не выполняет. Первый запуск даёт TS1206 — «Decorators are not valid here», ту самую ошибку, которую вы уже получали в песочнице, и код выхода 2. Второй запуск — тот же файл с --experimentalDecorators — не даёт ни одной диагностики: в старой системе такой код законен целиком.

Третий запуск и есть ответ на вопрос. TS5052: «Option 'emitDecoratorMetadata' cannot be specified without specifying option 'experimentalDecorators'». Заметьте, в диагностике нет ни имени файла, ни строки — это отказ настроек, файл компилятор даже не открыл. Метаданные не отдельная возможность, которую можно прикрутить к стандартным декораторам: это часть старой системы, и включается она только вместе с ней.

Обратный переход стоит столько же. Ключ решает за весь проект: двух систем в одном файле не бывает. «Перевести Nest на стандартные декораторы» значит выбросить разом @Body, @Param, @Query, @Inject и выдуманный компилятором список зависимостей — а на их месте в стандарте пусто: декораторы параметров вынесены в отдельное предложение первой стадии, и метаданных о типах в предложении о метаданных нет вовсе. Менять пришлось бы не Nest, а каждое приложение на нём.

В двенадцатой версии @Body, @Query и @Param научились принимать схему проверки: @Body({ schema }). Если бы Nest собирался менять систему декораторов, новая возможность была бы поводом сменить и подпись. Вот как это объявлено в @nestjs/common 12.0.3:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

ParameterDecorator — тип из библиотеки самого TypeScript, и означает он ровно старую подпись: функция от прототипа, имени метода и номера параметра. Схема здесь просто ещё одно поле в той же таблице параметров, а проверяет её труба, которую приложение обязано зарегистрировать само, — капкан, разобранный в статье о валидаторе границы.

Ключ в tsconfig — не настройка вкуса. Это выбор языка, на котором написаны все декораторы проекта.

Главная запись — __metadata("design:paramtypes", [NotesService]). Сама по себе она не делает ничего: помощник __metadata устроен как if (typeof Reflect.metadata === "function") return Reflect.metadata(k, v), а в чистом движке такой функции нет. Компилятор пишет вызов в пустоту и ждёт, что кто-нибудь положит на Reflect хранилище.

Положим сами. Хранилище — WeakMap по классу, как в своём реестре сведений. Прогноз: что окажется в таблице после того, как выполнится выпущенный компилятором код, — имена классов, строки с именами типов или пусто?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В таблице классы, а не их имена: NotesService и Clock — те самые конструкторы, по которым контейнер и найдёт провайдеров. Список, который у вашего каркаса писался руками в декораторе, здесь достался даром и разойтись с конструктором не может: его источник — сам конструктор. Настоящий reflect-metadata отличается от этих нескольких строк только тем, что таблица одна на всех и висит на глобальном Reflect, — поэтому её и хватает пакетам, которые друг о друге не знают.

Но выручает такая запись не со всяким типом. Прогноз к следующему примеру: у шести параметров разные типы, и у каждого свой ответ — что именно компилятор напишет в design:paramtypes?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор пишет не тип, а класс, который от типа останется после стирания. Класс остаётся собой. Интерфейс не остаётся ничем, и на его месте появляется Object — искать в контейнере по такому ключу нечего, поэтому перед параметром-интерфейсом и ставят внедрение по токену. Примитивам достаются обёртки String и Number.

Две средние строки — ловушки. Ref<MailService> — это псевдоним type Ref<T> = T, то есть буквально тот же MailService, но записать его компилятор уже не может: в записи может стоять только имя класса, а не вычисленный тип. И MailService | undefined — та же история: дописали к типу два слова, объединение перестало быть классом, и контейнер в тот же миг перестал видеть зависимость. Ни ошибки, ни предупреждения при этом не будет — будет Object.

Круг замыкается в контейнере Nest, и занимает это меньше десятка строк. PARAMTYPES_METADATA здесь — константа со значением "design:paramtypes".

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Сначала список от компилятора, потом поверх него — то, что объявлено руками: reflectSelfParams возвращает пометки от @Inject, и каждая замещает элемент по своему номеру. Отсюда правило: @Inject перед параметром всегда сильнее того, что написал компилятор, и ставят его там, где компилятору написать нечего.

И обратите внимание на || []. Нет записи — значит, нет зависимостей: классу без конструктора компилятор её и не пишет. Отличить такой класс от класса, у которого запись потерял сборщик, Nest не может ничем — этим и объясняется всё, что случится в предпоследнем разделе.

В метаданные попадает не то, что вы написали, а то, что от написанного остаётся в рантайме.

Вызов __decorate стоит в выпущенном файле сразу за классом и выполняется вместе с файлом — задолго до того, как контейнер начнёт кого-то создавать. Значит, имя типа обязано указывать на живое значение уже в этот момент. В обычном проекте разницы не видно: файл сервиса импортирует файл репозитория, тот выполняется первым, класс к моменту записи готов. Разница видна в круге.

Два сервиса ждут друг друга: UsersService просит MailService, MailService — UsersService. В проекте на ES-модулях это кончается ReferenceError ещё до Nest, и статья о «can't resolve» это показывала; падает как раз строка __metadata("design:paramtypes", [UsersService]) — имя в круге ещё не инициализировано. Ниже тот же круг, но в проекте на CommonJS: "type": "commonjs" в package.json, всё остальное слово в слово. Прогноз: дойдёт ли дело до Nest, и если дойдёт, на что он пожалуется?

прогон node 26.5.0 · 15 сентября 2026
круг двух сервисов в проекте на CommonJS: та же пара классов, другой конец
import 'reflect-metadata';
import { Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { UsersService } from './users.service.js';
import { MailService } from './mail.service.js';

@Module({ providers: [UsersService, MailService] })
class AppModule {}

async function bootstrap() {
  await NestFactory.createApplicationContext(AppModule);
  console.log('приложение собрано');
}

bootstrap();
nest start

Дошло. require в круге отдаёт наполовину заполненный объект экспортов — вы собирали это руками, — и компилятор пишет в метаданные не имя, а обращение к нему: __metadata("design:paramtypes", [users_service_js_1.UsersService]). Эта строка достаётся mail.service.js: он выполняется внутри круга, когда users.service.js до своего класса ещё не дошёл. Обращение к недостающему свойству не падает, оно даёт undefined. Так в списке зависимостей одного из двух сервисов и оказывается undefined вместо класса; у второго к его строке круг уже замкнулся, и там лежит настоящий класс.

Отсюда и вид сообщения: MailService (?) — вопрос вместо имени токена. Такой вид дают три причины: тип-интерфейс без @Inject, импорт одного типа вместо значения и круг файлов в проекте на CommonJS — во всех трёх компилятору нечего было записать. А имя исключения отделяет первую причину от двух остальных: от интерфейса в записи остаётся Object, Nest искал по нему и не нашёл — это UnknownDependenciesException; здесь же в записи undefined, искать нечего вовсе — UndefinedDependencyException.

Штатный обходной путь для круга — forwardRef: @Inject(forwardRef(() => MailService)) откладывает чтение имени до момента, когда контейнеру действительно понадобится токен. В проекте на CommonJS этого хватает. В ES-модулях — нет: токен и правда прочитается позже, но строка с design:paramtypes всё равно выполнится при объявлении класса и всё равно обратится к имени из круга. Падение остаётся тем же ReferenceError.

Собрать такой круг в ES-модулях удаётся, только спрятав тип от компилятора — тем самым псевдонимом из прошлого раздела: constructor(@Inject(forwardRef(() => MailService)) readonly mail: Ref<MailService>). Тогда в метаданные попадает Object, к имени компилятор не обращается, а токен приносит forwardRef. Проверено на Nest 12.0.3: приложение собирается, и обе стороны получают настоящие объекты друг друга. Обходной путь, которому приходится обходить ещё и компилятор, — лишний довод не городить круг, а вынести общее в третий класс.

Список зависимостей пишется в тот миг, когда выполняется файл класса. Всё, что к этому мигу не готово, в список не попадёт.

Раз список зависимостей пишет компилятор, вопрос «чем собирать» перестаёт быть вопросом вкуса и скорости. Ниже один и тот же класс проходит через четыре инструмента: tsc, SWC, Oxc и esbuild, а у SWC и Oxc ещё и с ключом метаданных и без него. Прогноз: у скольких из шести строк декоратор окажется применён и у скольких при этом появится запись о типах?

прогон node 26.5.0 · 22 сентября 2026
один класс, четыре компилятора: применён ли декоратор и что попало в design:paramtypes
// маленький Nest: пометка на классе и зависимость в конструкторе
function Injectable(): ClassDecorator {
  return () => {};
}

class NotesService {
  byId(id: number) {
    return { id, title: 'купить хлеб' };
  }
}

@Injectable()
export class NotesController {
  constructor(private readonly notes: NotesService) {}
}
node emit.mjs

Декоратор применили все шесть раз — это работа с синтаксисом, её умеет каждый. А запись о типах появилась у трёх: у tsc, которому ключи отданы в настройках, и у SWC с Oxc, когда им отдельно сказали decoratorMetadata и emitDecoratorMetadata. Без ключа оба молчат.

esbuild не пишет метаданные никогда, и ключ в tsconfigRaw ничего не меняет. Это решение, а не недоделка: esbuild разбирает файлы по одному и не знает, чем окажется имя NotesService в рантайме — классом, интерфейсом или псевдонимом. SWC и Oxc отвечают на этот вопрос догадкой: пишут имя под охраной typeof, а если в рантайме его не окажется — Object. tsc охраны не ставит вовсе: он видит, что NotesService объявлен тут же классом, а не стёртым типом, — и пишет имя голым. Важно другое: молчание esbuild тихое. Ни ошибки, ни предупреждения — декоратор в выводе стоит на месте, метаданных нет.

Под esbuild работает tsx, а tsx — самый привычный способ запустить .ts-файл. Прогноз: что будет, если запустить им настоящее приложение Nest — те самые файлы, которые в прошлой статье отвечали 200 и 404? Файлы не тронуты ни в одной букве, включая tsconfig.json с обоими ключами; другая только команда.

прогон node 26.5.0 · 22 сентября 2026
те же файлы приложения Nest, запущенные не через nest start, а через tsx
import 'reflect-metadata';
import { Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { NotesController } from './notes.controller.js';
import { NotesService } from './notes.service.js';

@Module({ controllers: [NotesController], providers: [NotesService] })
class AppModule {}

// список зависимостей контроллера, который вы писали руками, здесь уже готов
console.log('design:paramtypes', Reflect.getMetadata('design:paramtypes', NotesController));

// то же, что ваш createApp: контейнер, обход модулей, маршруты — в роутер платформы
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), { logger: false });
await app.init();

for (const request of [
  { method: 'GET', url: '/notes/1' },
  { method: 'GET', url: '/notes/99' },
  { method: 'GET', url: '/notes/abc' },
  { method: 'DELETE', url: '/notes/1' },
] as const) {
  const response = await app.inject(request);
  console.log(`${request.method} ${request.url} → ${response.statusCode} ${response.body}`);
}

const created = await app.inject({ method: 'POST', url: '/notes', payload: { title: 'молоко' } });
console.log(`POST /notes → ${created.statusCode} ${created.body}`);
// заметку добавил контроллер, а спрашиваем мы сервис, взятый из приложения: объект один
console.log('заметок у сервиса из контейнера:', app.get(NotesService).count());
console.log('контроллер тоже лежит в контейнере:', app.get(NotesController) instanceof NotesController);
await app.close();
tsx src/main.ts

Первая строка — design:paramtypes undefined, и дальше всё следствия. Приложение стартовало без единой жалобы: Nest не обязан требовать метаданные, класс без зависимостей их и не имеет, а отличить «нет зависимостей» от «список потерян» ему нечем. Контроллер создан, в поле notes лежит undefined, и рушится это на первом же обращении к сервису: 500 на трёх запросах из пяти.

Целы ровно те два ответа, которым сервис не нужен: 400 от ParseIntPipe — трубу в @Param("id", ParseIntPipe) передали явно, метаданные ей ни к чему, — и 404 роутера на DELETE. Строка про заметки добивает: у сервиса из контейнера их одна, а не две, потому что POST тоже упал. Код выхода 0.

Лога в выводе нет по одной причине: пример сам ставит logger: false. С логгером по умолчанию каждый 500 печатает ERROR [ExceptionsHandler] TypeError: Cannot read properties of undefined со стеком, и в скобках стоит метод, до которого не дошли: reading 'byId' у чтения, reading 'add' у создания. Процесс это тоже не роняет. Это самый дорогой вид поломки: сборка зелёная, старт чистый, проверка здоровья отвечает 200, а всё, что трогает базу, отвечает 500.

Тот же вопрос встаёт там, где о сборке обычно не думают, — в тестах: файлы компилирует раннер, своим компилятором и своими настройками. Приложение при этом собирается настоящим контейнером Nest, и если метаданных нет, тест получит ровно то же самое — объект с undefined вместо зависимости и падение в неожиданном месте. Чем это оборачивается на практике, разбирает статья о тестах против настоящей базы; проверка та же — напечатать запись и посмотреть, на месте ли она.

Сборщик, который «просто быстрее», здесь меняет поведение приложения. Молча.

Отсюда весь список рабочих способов. nest build и nest start по умолчанию зовут tsc — в nest-cli.json стартера о сборщике не сказано ни слова, а умолчание самого CLI записано объектом builder: { type: "tsc" }, — и он же проверяет типы. Это тот самый компилятор, о котором говорилось при выборе сборщика: для сервера бандл не обязателен, достаточно файла на файл.

Второй способ — nest build -b swc, заметно быстрее. Ключи метаданных при этом выставлять не нужно, билдер ставит их сам:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Те же два ключа, что в tsconfig.json, только названные по-своему. Цена скорости — SWC типы не проверяет вовсе: проверку CLI запускает отдельным процессом и только по флагу --type-check. Для монорепо в CLI 12 есть третий путь — Rspack: у подприложения билдер по умолчанию так и записан, а внутри у него тот же SWC с теми же двумя ключами. Прежний webpack остался, но при запуске просит перейти на Rspack.

Версия компилятора тоже не свободна. CLI зовёт компилятор как библиотеку, а в typescript 7.0 прежнего программного API у пакета нет: сам tsc остался, но как исполняемый файл. CLI это проверяет и отказывается — «does not expose the programmatic compiler API that the Nest CLI requires», — добавляя, что API ожидается в 7.1, и предлагая поставить TypeScript 6. Её и ставят: шестая версия стоит и в стартере Nest 12, и в носителе прогонов этого учебника.

А запустить .ts напрямую не выйдет дважды. Во-первых, стирание типов декораторов не понимает: node main.ts падает SyntaxError до первой строки вывода — это вы уже видели на стандартном декораторе, и со старым ровно то же самое. Во-вторых, даже если декораторы убрать, останется constructor(private readonly notes: NotesService) — а это параметр-свойство, не аннотация, а код, и Node отвечает ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. Флаг --experimental-transform-types, который раньше это решал, из Node 26 убран совсем.

Чем запускают или собираютДекораторыdesign:paramtypesГодится для Nest
node main.tsSyntaxError—нет
tsx main.ts (esbuild)данетнет: внедрение ломается молча
nest start, nest build (tsc)дадада, и типы проверены
nest build -b swcдада, ключ ставит билдерда; типы — отдельно, --type-check
nest build -b rspack (монорепо)дада, ключ ставит билдерда; внутри тот же SWC

Правило, которое из этого следует, короткое: в проекте на Nest инструмент запуска выбирается один раз и меняется только вместе с проверкой, что метаданные на месте. Одной строки console.log(Reflect.getMetadata("design:paramtypes", NotesController)) при старте хватает, чтобы поломка объявила о себе сразу, а не первым запросом в проде.

Свой каркас можно было запускать чем угодно. Чужой каркас приносит с собой свой список инструментов — и это часть цены.

  • Старая система декораторов включается ключом experimentalDecorators и даёт две вещи, которых нет в стандарте: декораторы параметров и запись типов в метаданные. На первом держатся @Body, @Param, @Query и @Inject, на втором — список зависимостей конструктора.
  • Взять из старой системы одни метаданные нельзя: emitDecoratorMetadata без experimentalDecorators — это TS5052, отказ настроек до чтения файлов. Поэтому переход Nest на стандартные декораторы означал бы переписать каждое приложение, а не сам фреймворк; @Body({ schema }) из 12-й версии — по-прежнему ParameterDecorator.
  • В design:paramtypes попадает не тип, а класс, который от него останется после стирания: класс — собой, интерфейс, псевдоним и объединение с undefined — Object, примитивы — обёртками. Сам вызов __metadata пуст, пока кто-нибудь не положит хранилище на Reflect: этим и занят reflect-metadata.
  • Запись выполняется при объявлении класса, а не при сборке контейнера. В круге ES-модулей это ReferenceError на самой строке с метаданными, в круге CommonJS — undefined в списке и UndefinedDependencyException. В CommonJS круг лечится forwardRef, в ES-модулях — только если тип заодно спрятан от компилятора псевдонимом.
  • Декораторы переписывают все компиляторы, метаданные пишут tsc, SWC и Oxc — последние два только по ключу, — а esbuild не пишет никогда и не предупреждает об этом. Поэтому под tsx приложение Nest стартует чисто и отвечает 500 на каждой ручке с зависимостью.
  • nest build и nest start — это tsc; -b swc и Rspack в монорепо ставят оба ключа сами, но типы не проверяют; TypeScript 7 CLI не принимает; node main.ts не пройдёт ни декоратор, ни параметр-свойство.

1/4Что делает помощник __param(0, Body()) в выводе компилятора?

2/4В проекте на стандартных декораторах захотели оставить всё как есть, но получить список зависимостей: дописали в tsconfig.json один emitDecoratorMetadata. Что ответит tsc?

3/4У провайдера поменяли тип параметра конструктора с Repo на Repo | undefined, больше ничего не трогали. Что окажется в design:paramtypes?

4/4Приложение Nest запустили под tsx. Что произойдёт?

Зависимость, которую компилятор не увидел

Сервису нужен репозиторий, но в метаданных вместо класса стоит Object, и контейнер такого провайдера не найдёт. Поправьте тип параметра так, чтобы в design:paramtypes попал NotesRepo. Строку с @Injectable() и тело класса не трогайте.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Причина — | undefined в типе параметра: объединение после стирания перестаёт быть классом, и компилятор пишет Object. Необязательность выражают не типом параметра, а самим контейнером — у Nest для этого есть @Optional().

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод становится таким:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Тот же Object вышел бы от псевдонима Ref<NotesRepo>, от интерфейса вместо класса и от import type вместо обычного импорта. Проверка одинаковая: напечатать запись и посмотреть, класс в ней или Object.

Отказ вместо тихого undefined

Свой контейнер читает список зависимостей из той же таблицы, что и Nest, и с потерянной меткой поступает так же, как Nest под tsx: чего не понял — подставил undefined, и сервис собрался наполовину. Сделайте отказ громким: контейнер должен назвать класс и номер параметра, а не отдавать полупустой объект. Замените строку с пометкой, остальное не трогайте.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Отсутствие записи у класса — законный случай: design:paramtypes компилятор не пишет классу, у которого нет конструктора, и пустой список для контейнера нормален. А вот undefined или Object внутри записи законным не бывает: искать по такому ключу нечего.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Так же поступает и Nest, и отсюда два его разных исключения: UndefinedDependencyException, когда в записи пусто, и UnknownDependenciesException, когда класс в записи есть, но в модуле его нет. Обратите внимание, чего проверка сделать не может: отличить класс без конструктора от класса, у которого метку потерял сборщик. Записи нет в обоих случаях — это и есть та слепота, из-за которой приложение под tsx стартует молча.