Метаданные без reflect-metadata: свой реестр на WeakMap

каркас≈ 13 минут

Здесь будет встроен видео-разбор темы (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 и появится ли у класса символьный ключ?

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

Объекта нет, символа нет, символьных ключей у класса ни одного. Причина — строка из вывода компилятора, который мы уже разбирали: 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?

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

Два маршрута, оба нашлись по функциям. Имена даёт 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 ниже одни и те же два декоратора, только в разном порядке. Прогноз: у какого метода таблица найдёт путь и сработает ли сама обёртка?

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

У 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 добавляет роль к уже известным. Прогноз: какие роли окажутся у предка и у наследника?

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

У обоих ["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?

прогон node 26.5.0 · 15 сентября 2026
Маршрут пишется в context.metadata: предок, наследник и символьные ключи класса — под tsx
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);
tsx meta.ts

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 символа нет. Прогноз: у какого из двух классов появится объект сведений?

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

У 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 в Nestcontext.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 так, чтобы маршрут находился при любом порядке.

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

Обёртка должна перенести путь исходной функции на себя. Для этого её нужно сохранить в переменную, прежде чем вернуть.

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

Вывод:

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

У 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 должен попасть в список один раз — со своей функцией, а не с функцией предка.

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

Нужно подниматься по цепочке прототипов и запоминать уже встреченные имена: первым встречается самое нижнее, то есть переопределённое.

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

Вывод:

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

Цикл останавливается на Object.prototype: дальше методы, которые маршрутами не бывают. Множество seen не пускает имя второй раз, поэтому history предка пропускается, хотя путь у его функции есть. Префикс берётся у самого ArchiveController: его записал собственный декоратор класса.

Так же обходит прототипы Nest: его сканер методов идёт вверх по цепочке и пропускает уже встреченные имена. Отсюда следствие, которое стоит проверить руками: уберите @Get у переопределённого history. Маршрут истории пропадёт совсем — имя занято функцией наследника, а у неё пути нет.

Обязательные поля тела

Декоратор @Requires записывает, какие поля тела нужны методу. Функция call вызывает метод контроллера по имени, но записи не читает, и заметка без текста создаётся. Сейчас вторая заметка создаётся без текста, а вместо неё должна напечататься строка 400: нет поля text. Допишите проверку в call.

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

Проверка читает запись по той же функции, которую собирается вызвать, — в момент вызова, а не при объявлении класса.

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

Вывод:

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

Object.hasOwn, а не field in body: оператор in нашёл бы в прототипе тела, например, поле toString, если бы метод его потребовал. У метода без декоратора записи нет, и ?? [] превращает её в пустой список — такой метод вызывается без проверки.

Декоратор ничего не проверяет сам: он только оставил сведения, а проверяет тот, кто вызывает метод.