Метаданные без reflect-metadata: свой реестр на WeakMap
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Прошлая статья оставила декоратор @Get("/:id") с путём и именем метода, но без класса, в котором метод лежит. Маршрутизатору же нужна запись «GET /notes/:id → NotesController.byId», которую можно найти по классу, когда придёт запрос. Таких записей у каркаса много: путь у метода, префикс у контроллера, роль, схема ввода. Nest держит их пакетом reflect-metadata, стандарт предлагает для них общий объект класса, а в песочнице нет ни пакета, ни объекта. Соберём хранилище сами — реестр на WeakMap — и пройдём две ловушки, которые у него общие с настоящими: обёртку, после которой запись не находится, и наследника, который дописывает свою роль в массив предка.
У стандартных декораторов есть соседнее предложение — о метаданных. Оно обходится без класса в руках декоратора: у каждого класса заводится один объект сведений, общий для всех его декораторов. Декоратор метода пишет в context.metadata, декоратор класса получает тот же объект, а когда класс объявлен, объект остаётся в самом классе под ключом Symbol.metadata. Маршрутизатору остаётся прочитать NotesController[Symbol.metadata]. Фабрика Get ниже пока только смотрит, что лежит в этом поле. Прогноз: есть ли объект в context.metadata, знает ли движок Symbol.metadata и появится ли у класса символьный ключ?
Объекта нет, символа нет, символьных ключей у класса ни одного. Причина — строка из вывода компилятора, который мы уже разбирали: const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0. tsc создаёт объект сведений, только если в движке есть символ, под которым объект потом ляжет в класс. Ни в Chrome, ни в Firefox, ни в Node 26 этого символа нет.
Компилятор знает это и по типам. Строка с Symbol.metadata подчёркнута: «Свойство "metadata" не существует в типе "SymbolConstructor"», в терминале TS2550. Символ описан только в библиотеке типов esnext, а песочница проверяет код по ES2023. От символа зависит и тип поля: context.metadata описан условием — DecoratorMetadataObject, если типы знают Symbol.metadata, и DecoratorMetadataObject | undefined, если нет. В песочнице второй случай, и запись context.metadata.path = path получит TS18048 — «Возможно, "context.metadata" имеет значение undefined», — а при запуске TypeError. Стандартный путь в песочнице закрыт, и каркасу нужно хранилище, которое от движка не зависит.
Класса у декоратора метода нет, зато есть сам метод — функция, которая останется в прототипе. Её и можно сделать ключом: «у этой функции путь /:id». Декоратор класса получает класс и записывает префикс по нему. Маршрутизатор потом берёт класс, перебирает функции его прототипа и про каждую спрашивает таблицу. Так поступает и Nest: @Get кладёт путь на функцию-обработчик, Reflect.defineMetadata(PATH_METADATA, path, descriptor.value), а маршрутизатор читает путь у функции, которую находит в прототипе контроллера. Таблицы у нас — WeakMap, по одной на вид сведений. Прогноз: сколько маршрутов напечатается и попадут ли в них format и constructor?
Два маршрута, оба нашлись по функциям. Имена даёт Object.getOwnPropertyNames, а не Object.keys: методы класса неперечисляемые, и Object.keys прототипа вернул бы пустой массив. Имён четыре: constructor, byId, history и format. Но format никто не помечал, а под именем constructor лежит сам класс, и префикс записан по нему в другой таблице — в таблице путей его нет. Классу и прототипу реестр не добавил ничего: Object.keys, JSON.stringify и перебор увидят их такими же, как без декораторов, а прочитать сведения может только тот, у кого есть сама таблица.
Почему WeakMap, а не обычная Map. Ключ здесь сам объект, а не имя, поэтому два разных контроллера с именем NotesController из разных файлов не затрут записи друг друга; это дала бы и Map. Отличие в памяти: слабый ключ не держит объект. Класс, созданный на время — в тесте или фабрикой классов, — уйдёт вместе со своими записями, а обычная Map хранила бы его, пока жив реестр. Та статья называла типичным случаем для WeakMap служебные пометки к чужим объектам, и реестр метаданных ровно такой: пометки к классам и функциям, которые каркасу не принадлежат.
Запись о методе живёт по функции, а не по имени. Какая функция лежит в прототипе, у той и спрашивают.
У ключа-функции есть цена. Декоратор logged из прошлой статьи возвращает обёртку, и в прототип встаёт она, а не метод. Над byId и history ниже одни и те же два декоратора, только в разном порядке. Прогноз: у какого метода таблица найдёт путь и сработает ли сама обёртка?
У byId путь нашёлся, у history — undefined, хотя @Get стоит над обоими, а обёртка у history исправно печатает вызов. Дело в порядке. Декораторы применяются снизу вверх, начиная с ближайшего к методу. Над history первым сработал Get и записал путь на исходную функцию, затем logged вернул обёртку, и в прототип легла она. Таблица честно хранит путь функции, которой в прототипе больше нет. Над byId наоборот: сначала обёртка, потом Get, и он получил уже её.
Ошибки нет никакой: маршрутизатор, не найдя пути, просто считает метод не маршрутом. Nest ведёт себя так же — у функции без пути он маршрут не заводит, и запрос получит 404. Лечений два. Можно держать правило «декораторы, которые пишут сведения, — над обёртками». Надёжнее, когда обёртка сама переносит сведения исходной функции на себя: тогда порядок перестаёт значить, это первая задача статьи. У стандартного context.metadata этой ловушки нет: сведения лежат в объекте класса, и заменённая функция их не уносит.
Сведения о классе должны наследоваться. Контроллер-наследник получает методы предка, и роль, назначенную предку, логично требовать и у него. Поэтому сведения класса читают по цепочке. extends связывает не только прототипы экземпляров, но и сами классы: Object.getPrototypeOf(AdminNotesController) — это NotesController. Функция rolesOf поднимается по этой цепочке до первой записи. Так же читает reflect-metadata: Reflect.getMetadata идёт по прототипам, а Reflect.getOwnMetadata смотрит только сам объект. Декоратор Roles добавляет роль к уже известным. Прогноз: какие роли окажутся у предка и у наследника?
У обоих ["user", "admin"]. Предку досталась роль, которую ему никто не назначал, хотя в классе NotesController не поменялось ни строки. Если страж пускает только с каждой ролью из списка, обычный пользователь к своим заметкам больше не пройдёт. Декоратор наследника спросил rolesOf(AdminNotesController). Своей записи у класса ещё не было, и поиск вернул массив предка — тот самый объект, а не копию. push дописал его, а roles.set положил тот же массив ещё и наследнику. Правило «читаем вверх, пишем вниз» здесь не спасает: вниз, в сам объект, идёт присваивание, а push ничего не присваивает. Он меняет найденный массив там, где тот лежит, — у предка.
Лечение — не менять найденное, а собрать новый массив: roles.set(value, [...(rolesOf(value) ?? []), role]). Тогда у предка останется ["user"], у наследника будет ["user", "admin"]. В Nest ровно так устроена служебная функция extendArrayMetadata, через которую идут @UseGuards, @UsePipes и @UseInterceptors: прочитать прежнее значение по цепочке, собрать [...previousValue, ...metadata], записать новый массив на свою цель. А читает Nest стражей класса через Reflect.getMetadata, то есть по той же цепочке.
Найденное по цепочке прототипов — чужое. Дописывать можно только свою копию.
Вернёмся к стандартному объекту. Каркас части живёт не только в песочнице: когда он ответит на настоящем сокете, его выполнит tsx, а tsx, как мы уже видели, переписывает декораторы через esbuild. Ниже декоратор пишет маршрут прямо в context.metadata, а наследник добавляет свой. Прогноз для tsx: будет ли объект, раз Symbol.metadata в Node нет, и какие маршруты окажутся у NotesController?
function Get(path: string) { return function (method: Function, context: ClassMethodDecoratorContext) { const meta = context.metadata as { routes?: string[] }; (meta.routes ??= []).push(`GET ${path}`); }; } class NotesController { @Get("/:id") byId(id: string) { return `заметка ${id}`; } } class ArchiveController extends NotesController { @Get("/archive") archive() { return []; } } console.log(typeof Symbol.metadata); const [key] = Object.getOwnPropertySymbols(NotesController); console.log(key, key === Symbol.for("Symbol.metadata")); console.log(Reflect.get(NotesController, key).routes); console.log(Reflect.get(ArchiveController, key).routes);
undefined Symbol(Symbol.metadata) true [ 'GET /:id', 'GET /archive' ] [ 'GET /:id', 'GET /archive' ]
Symbol.metadata в Node по-прежнему undefined, а объект есть, и лежит он в классе под ключом Symbol(Symbol.metadata). Это Symbol.for("Symbol.metadata") — символ из глобального реестра, один и тот же для всего кода. esbuild не проверяет движок, как tsc: его помощник берёт Symbol.metadata, если тот есть, иначе подставляет свой символ, а объект сведений создаёт в любом случае. Один и тот же файл даёт сведения под tsx и undefined в песочнице.
Две последние строки — ловушка прошлого раздела в стандартном исполнении. Объект сведений наследника создаётся с объектом предка в прототипе, чтобы наследник видел сведения предка. meta.routes ??= [] читает по цепочке, находит массив предка, и присваивания не происходит: значение не undefined. push дописывает массив предка, и маршрут /archive оказался у NotesController, где метода archive нет вовсе. tsc там, где символ в движке есть, строит объект наследника так же — Object.create от сведений предка. Ловушка не в компиляторе и не в реестре, а в самой идее: сведения наследника наследуют сведения предка.
Почему же каркас держит свою таблицу, если под tsx стандартный объект работает? Во-первых, один и тот же код каркаса выполняется и в песочнице, и под tsx, а сведения здесь есть в одном месте и нет в другом. Во-вторых, предложение о метаданных не закончено: 19 мая 2026 года комитет вернул его на стадию 2.7 вместе с декораторами, и форма объекта ещё может поменяться. Таблица на WeakMap не зависит ни от движка, ни от компилятора. А когда стандарт выйдет, поменяются тела декораторов и чтение сведений, но не контроллеры, над которыми эти декораторы стоят.
Помощник tsc проверяет символ при объявлении каждого класса, поэтому символ можно подставить одной строкой, как это делают полифилы. Типы придётся обойти приведением: в библиотеке ES2023 символа нет. Прогноз: у какого из двух классов появится объект сведений?
У Early — undefined, у Late — объект и ключ Symbol(Symbol.metadata). Подстановка работает, но только для классов, объявленных после неё. В одном файле это видно глазами. В проекте из модулей строка-полифил должна выполниться раньше первого импортированного контроллера, иначе часть классов молча останется без сведений. У reflect-metadata та же зависимость от порядка: помощник, который tsc вставляет ради emitDecoratorMetadata, проверяет Reflect.metadata в момент объявления класса и без пакета не записывает ничего, тоже молча.
У Nest третий путь. reflect-metadata — полифил старого предложения API Reflect.defineMetadata: пакет вешает функции на глобальный Reflect, а внутри держит WeakMap от объекта к картам ключей — таблицу вроде нашей, только одну на всех и видную любому коду. В README пакета сказано прямо: после того как стандартные декораторы и их метаданные продвинулись в комитете, этот API к стандартизации больше не рассматривают, а пакет поддерживают ради проектов на experimentalDecorators. Nest 12 — такой проект: reflect-metadata стоит в peerDependencies его пакетов, а главные файлы @nestjs/common и @nestjs/core первым делом импортируют его сами — раньше любого класса с их декораторами, так что ловушки порядка в Nest нет. Через этот же API компилятор записывает типы параметров конструктора, а зачем они Nest и чем такой проект собирать, разберёт статья «Почему Nest живёт на старых декораторах».
| Свой реестр | reflect-metadata в Nest | context.metadata | |
|---|---|---|---|
| сведения метода | в таблице по функции | в таблице пакета по функции-обработчику | в объекте класса |
| обёртка над методом | уносит запись, если не перенести | уносит запись, если не перенести | не мешает |
| наследование | по цепочке, если так прочитать | getMetadata — по цепочке, getOwnMetadata — нет | объект наследника наследует объект предка |
| что нужно | ничего | пакет, загруженный раньше классов | символ в движке или компилятор, который создаёт объект сам |
| песочница и tsx | работает и там и там | в песочнице пакетов нет | в песочнице undefined, под tsx работает |
Сведения о маршрутах и ролях каркас теперь умеет оставить и найти. Не хватает тех, без которых он не создаст ни одного объекта: что нужно сервису, чтобы появиться на свет. С ними работает свой контейнер.
- Декоратор метода не получает класс, поэтому запись о маршруте, роли или схеме ввода он оставляет там, откуда её достанет каркас. Стандарт предлагает общий объект класса
context.metadata, но tsc создаёт его, только если в движке естьSymbol.metadata. В песочнице тамundefined, а сам символ даётTS2550. - Свой реестр —
WeakMap, по таблице на вид сведений: путь — по функции-методу, префикс — по классу. Маршрутизатор перебирает функции прототипа и спрашивает таблицу, как Nest спрашивает reflect-metadata. Классу ничего не добавляется, а слабый ключ не держит его в памяти. - Запись по функции теряется, если декоратор-обёртка стоит над записывающим: путь остаётся у исходной функции, в прототипе лежит обёртка, и маршрут пропадает без ошибки. Обёртка должна переносить сведения на себя.
- Сведения класса читаются по цепочке прототипов, и найденный массив принадлежит предку:
pushв него меняет предка. Нужен новый массив,[...прежние, новое], — так устроенаextendArrayMetadataв Nest. - Под tsx esbuild создаёт объект сведений всегда, с ключом
Symbol.for("Symbol.metadata"), и ловушка наследования у него та же. Предложение о метаданных на стадии 2.7, поэтому каркас держит свою таблицу, которая одинаково работает в песочнице и под tsx. - Nest хранит сведения пакетом reflect-metadata: глобальный
Reflect.defineMetadataповерхWeakMap. Пакет поддерживают радиexperimentalDecorators. Загруженный позже классов, он молча оставил бы их без типов параметров конструктора, поэтому@nestjs/commonи@nestjs/coreимпортируют его первым делом.
1/4В песочнице декоратор метода печатает typeof context.metadata и получает undefined. Почему?
2/4Над методом стоит @logged, под ним — @Get("/:id"); logged возвращает обёртку. Путь записывается в WeakMap по функции, а маршрутизатор ищет его по функции из прототипа. Что он найдёт?
3/4Декоратор делает const list = rolesOf(value) ?? []; list.push(role); roles.set(value, list), а rolesOf ищет запись по цепочке прототипов. Над предком @Roles("user"), над наследником @Roles("admin"). Какие роли будут у предка?
4/4Зачем реестру метаданных WeakMap, а не Map?
Обёртка, которая не теряет маршрут
Над byId декоратор logged стоит выше @Get, над history — ниже. Сейчас печатается undefined /:id/history, а нужно /:id /:id/history. Строки с @ не трогайте: почините logged так, чтобы маршрут находился при любом порядке.
Обёртка должна перенести путь исходной функции на себя. Для этого её нужно сохранить в переменную, прежде чем вернуть.
Вывод:
У byId первым сработал Get, путь уже лежит у исходной функции, и logged копирует его на обёртку. У history первым сработал logged: у исходной функции пути ещё нет, копировать нечего, а Get затем получает обёртку и пишет путь прямо на неё. Одна проверка покрывает оба порядка.
Цена такого решения видна сразу: logged должен знать таблицу путей. Появятся таблицы ролей и схем — придётся переносить и их. Поэтому хранилище удобнее держать одной таблицей «объект → все его сведения» с функцией переноса всего сразу. Так устроен reflect-metadata, а у маршрутизатора Nest есть copyMetadataToCallback: он переносит все ключи с обработчика на функцию, которую сам строит вокруг него.
Маршруты, унаследованные от предка
ArchiveController наследует NotesController и переопределяет history. printRoutes видит только собственные функции прототипа и печатает один маршрут. Нужно два, в таком порядке: GET /archive/:id/history → history и GET /archive/:id → byId. Переопределённый history должен попасть в список один раз — со своей функцией, а не с функцией предка.
Нужно подниматься по цепочке прототипов и запоминать уже встреченные имена: первым встречается самое нижнее, то есть переопределённое.
Вывод:
Цикл останавливается на Object.prototype: дальше методы, которые маршрутами не бывают. Множество seen не пускает имя второй раз, поэтому history предка пропускается, хотя путь у его функции есть. Префикс берётся у самого ArchiveController: его записал собственный декоратор класса.
Так же обходит прототипы Nest: его сканер методов идёт вверх по цепочке и пропускает уже встреченные имена. Отсюда следствие, которое стоит проверить руками: уберите @Get у переопределённого history. Маршрут истории пропадёт совсем — имя занято функцией наследника, а у неё пути нет.
Обязательные поля тела
Декоратор @Requires записывает, какие поля тела нужны методу. Функция call вызывает метод контроллера по имени, но записи не читает, и заметка без текста создаётся. Сейчас вторая заметка создаётся без текста, а вместо неё должна напечататься строка 400: нет поля text. Допишите проверку в call.
Проверка читает запись по той же функции, которую собирается вызвать, — в момент вызова, а не при объявлении класса.
Вывод:
Object.hasOwn, а не field in body: оператор in нашёл бы в прототипе тела, например, поле toString, если бы метод его потребовал. У метода без декоратора записи нет, и ?? [] превращает её в пустой список — такой метод вызывается без проверки.
Декоратор ничего не проверяет сам: он только оставил сведения, а проверяет тот, кто вызывает метод.