Стандартные декораторы: что они делают на самом деле

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

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

Контроллер Nest выглядит как класс, обвешанный строками с @: @Controller("notes") над классом, @Get(":id") над методом, @Body() перед параметром. Похоже на настройку, которую кто-то читает со стороны. На деле за каждой такой строкой стоит вызов обычной функции в момент, когда объявляется класс. В этой главе мы соберём сердце Nest сами — декораторы, метаданные, контейнер — и начнём с самого @: что получает функция-декоратор, что может вернуть, когда её зовут и почему @Body() в параметре стандартный TypeScript не пропускает вовсе.

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

Декоратор — это функция. Строка @logged над методом просит: когда класс будет объявлен, позови logged, передай ей сам метод и объект с описанием места — контекст, — а если она вернёт новую функцию, поставь её вместо метода. Декоратор ниже возвращает обёртку: она печатает каждый вызов и передаёт работу исходному методу. Прогноз: в каком порядке появятся строки, сколько раз напечатается «декоратор получил add» при двух экземплярах и что покажет последняя строка?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
«Декоратор получил add» — одна строка, и стоит она между «перед классом» и «после класса». Декоратор отработал ровно тогда, когда выполнялось объявление класса, до первого new. Ни экземпляры, ни вызовы метода его больше не зовут: на каждом вызове работает обёртка, которую он вернул. Обёртка — обычное замыкание, она помнит method и context той единственной встречи с классом. А true в последней строке говорит, где обёртка живёт. Методы класса лежат в прототипе, и декоратор заменил функцию там — одну на все экземпляры.

Три детали в обёртке не случайны. Она объявлена через function, а не стрелкой: ей нужен свой this — объект, у которого вызвали метод, — и method.apply(this, args) передаёт его исходному методу, как в статье про call и apply. Стрелка взяла бы this самого декоратора, а его зовут без объекта: там undefined, и метод, которому нужен this, упал бы с TypeError. Запись this: unknown в скобках — не параметр, а подпись для компилятора, какой this ждёт функция; при выполнении она стирается, как любой тип. И String(context.name): имя члена класса бывает символом, а символ в шаблонную строку сам не превращается.

Всё, что должно происходить на каждом вызове, живёт в обёртке. Сам декоратор про вызовы ничего не знает: к тому времени он давно отработал.

Браузер декораторов не понимает — ни один движок их пока не выполняет, об этом последний раздел. Песочница отдаёт браузеру то, что компилятор выпустил из примера функцией transpileModule; ею в части про сборку переводили файлы с JSX и типами. Переведём класс с одним декоратором и напечатаем, что компилятор написал на его месте. Прогноз: где окажется вызов logged — в объявлении метода, в конструкторе или где-то ещё?

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

Декоратор зовут из блока static { … }. Это статический блок класса: код, который выполняется один раз, пока класс объявляется, — отсюда и строка «декоратор получил add» до первого new. __esDecorate — помощник из начала файла. Он берёт метод из прототипа, зовёт с ним декораторы из списка [logged], начиная с последнего, то есть ближайшего к методу, и возвращённую функцию ставит на место метода. Принять он готов только функцию или undefined: на строку или число помощник отвечает TypeError: Function expected, а компилятор ещё до запуска — ошибкой типа.

Четвёртый аргумент — тот самый контекст, записанный литералом: kind: "method", имя, static, private, объект access с функциями чтения и metadata. Объект для метаданных компилятор создаёт, только если в движке есть Symbol.metadata. В песочнице его нет, поле получает undefined, и что с этим делать, разберёт следующая статья. Метода addInitializer в литерале нет: помощник добавляет его в контекст сам, а функции, переданные в него, складывает в массив _instanceExtraInitializers. Этот массив выполняет конструктор — к нему вернёмся через два раздела.

Декоратор можно поставить на сам класс и на его метод, геттер, сеттер или поле, статические и приватные тоже: тип DecoratorContext объединяет контексты всех этих видов. Декоратор where ничего не меняет, только печатает, что узнал о месте. Прогноз: в каком порядке выйдут шесть строк и совпадёт ли он с порядком в тексте класса?

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

Порядок не текстовый. Первым вызван статический create, хотя в классе он третий. За ним методы и геттер экземпляра, потом поле, и последним — декоратор класса, хотя он стоит выше всех. Так зовут и tsc, и esbuild. Класс последним по делу: его декоратор получает класс, в котором члены уже украшены, и может заменить его целиком — другим классом. У поля первый аргумент undefined: пока класс объявляется, значения нет, оно появится в каждом экземпляре. У приватного метода имя — строка "#match", но это только подпись. Приватный член с решёткой по строковому ключу не достать, для этого в контексте есть access.get.

Поле контекстаЧто в нём
kindвид: "class", "method", "getter", "setter", "field" или "accessor" — поле со словом accessor, появившимся вместе с декораторами
nameимя члена или класса; у приватного — подпись вида "#match"
static, privateстатический ли член и приватный ли; у класса этих полей нет
accessфункции get и set для этого члена на переданном объекте: у метода и геттера только чтение, у сеттера только запись, у поля и то и другое; у класса нет
addInitializerотложить работу на потом — раздел ниже
metadataобщий объект сведений о классе; в песочнице undefined

Главного в таблице нет: самого класса. Декоратор метода получает функцию и описание, но не класс, в котором она лежит, и не прототип. Это решение стандарта, а не упущение TypeScript.

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

У Nest декораторы пишутся со скобками и часто с аргументами. После @ может стоять любое выражение, которое даёт функцию, в том числе вызов. Get("/:id") — не декоратор, а фабрика: обычная функция, которая запоминает путь и возвращает настоящий декоратор. Обе фабрики ниже печатают, когда их вычислили, а декораторы, которые они вернули, — когда их позвали. Прогноз: в каком порядке выйдут шесть строк?

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

Сначала три «вычислено» — сверху вниз, как написано, начиная с класса. Потом декораторы методов и последним декоратор класса, в порядке из прошлого раздела. Это два разных шага. Выражения после @ вычисляются, пока читается объявление, а декораторы зовутся, когда методы уже определены. Для фабрики это значит, что аргументы — путь, роль, схема ввода — известны заранее, а пользоваться ими декоратор будет позже.

Теперь посмотрите, чего не хватает маршрутизатору. Get("/:id") знает путь и имя метода, byId, но не знает, что метод лежит в NotesController с префиксом /notes: класса в его контексте нет. Каркасу же нужна запись «GET /notes/:id → NotesController.byId», которую можно найти по классу, когда придёт запрос. Где декоратор метода оставляет такую запись и как её достать — вопрос следующей статьи.

Декоратор метода работает, когда экземпляров ещё нет, а иногда работа нужна именно в экземпляре. Классический случай — метод, положенный в переменную: const increment = counter.increment теряет this. Лечение из первой части — bind, но привязать метод можно только к конкретному объекту. Для этого у контекста есть addInitializer: переданная в него функция выполнится позже, и для члена экземпляра this в ней — создаваемый объект. Декоратор bound кладёт в экземпляр привязанную копию метода. Прогноз: сработает ли оторванный increment и в каком порядке окажутся ключи экземпляра?

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

Оторванный метод работает: 1 2. Инициализатор записал в экземпляр собственное свойство increment — привязанную копию, — и оно заслонило метод прототипа. А порядок ключей показывает, когда это случилось: increment появился раньше count. Инициализаторы методов выполняются до полей. В выводе из второго раздела полей не было, и __runInitializers стоял в конструкторе. У Counter поле есть, и компилятор вписывает вызов прямо в его инициализатор, перед значением: count = (__runInitializers(this, _instanceExtraInitializers), 0). Прочитать поле в таком инициализаторе нельзя: его ещё нет.

Под примером компилятор возражает: «Объект имеет тип "Неизвестный"», в терминале это TS2571. Запуску это не мешает, но причина поучительна. Декоратор — отдельная функция, её можно поставить в любой класс, и ClassMethodDecoratorContext без аргументов типа не знает, в какой: this в инициализаторе описан как unknown. Параметр типа, как в обобщённой функции, тут не спасёт. С ClassMethodDecoratorContext<This> в инициализаторе окажется This, но внутри декоратора это по-прежнему какой угодно тип без индексной сигнатуры, и запись по произвольному имени компилятор не пропустит, только ошибка сменится на TS7053. Неизвестный объект нужно не индексировать, а отдать функции, которая принимает любой:

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

Ноль диагностик, хотя this остался неизвестным. Object.defineProperty описан так, что первым аргументом берёт что угодно, а вторым — любое имя свойства, строку или символ. Поэтому ему подходят и this типа unknown, и context.name, а привязанная копия встаёт в экземпляр так же, как в Counter.

Когда выполняется инициализатор, зависит от того, что украшено. Проверено и в песочнице, и под tsx:

Декоратор стоит наИнициализатор выполняетсяthis в нём
методе или геттере экземплярав конструкторе, до полейэкземпляр
поле экземплярасразу после того, как это поле получило значениеэкземпляр
статическом методе или геттерепри объявлении класса, до статических полейкласс
классепосле объявления класса, когда статические поля уже заданыкласс

В Nest тело запроса попадает в метод контроллера через декоратор параметра: create(@Body() dto). Попробуем то же в песочнице. Body() — фабрика, которая сообщает о своём вызове. Прогноз: напечатает ли пример «Body() вызван»?

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

Компилятор отвечает «Декораторы здесь недопустимы», в терминале это TS1206. Печатается только тело: Body() не вызвался ни разу. Из выпущенного кода декоратор исчез, от метода осталось create(dto) { return dto; }. Так же поступает tsc в терминале: ошибка, код выхода 2 — и файл, в котором декоратора нет. Ошибка компилятора, как вы помните, выпуск не отменяет, поэтому сборка, которая не смотрит на диагностики, получит работающий на вид код, где @Body молча ничего не делает.

Декораторов параметров в стандарте нет не по забывчивости. Модель стандартного декоратора — «получил значение, вернул замену того же вида». У параметра, пока объявляется класс, значения нет: аргумент появится только при вызове, а сам параметр в JavaScript не объект — его не передать в функцию и не заменить. Декоратору параметра остаётся одно: оставить каркасу пометку «в первый аргумент этого метода положи тело запроса». В старой системе декораторов TypeScript так и устроено: декоратор параметра получает прототип, имя метода и номер параметра и ничего не возвращает. Предложение стандарта параметры прямо не включает. Для них в комитете TC39 есть отдельное предложение, и с марта 2023 года оно стоит на первой стадии: комитет согласился изучать задачу, но решения ещё не выбрал.

Старые: experimentalDecoratorsСтандартные: песочница, tsx
декоратор метода получаетпрототип, имя метода и дескриптор свойствасам метод и контекст
декоратор параметраесть: прототип, имя метода и номер параметранет, TS1206
класс в руках декоратора методада, через прототипнет
типы параметров в метаданныхс флагом emitDecoratorMetadataнет

Отсюда развилка всей части. Nest построен на старой системе: в tsconfig.json стартового проекта Nest 12 включены experimentalDecorators и emitDecoratorMetadata, а @Body и @Param в методах контроллера и @Inject в конструкторе — декораторы параметров. Наш каркас пишется на стандартных и обходится без них: метод контроллера получает всё о запросе одним объектом, а сервис объявляет свои зависимости списком — так их прочитает свой контейнер. Откуда в Nest берётся constructor(private repo: Repo) без единой пометки и чем такой проект собирать, разберёт статья «Почему Nest живёт на старых декораторах».

В песочнице декораторы переписывает компилятор TypeScript. Сервер части VI запускает Node, а встроенный запуск .ts типы только стирает. Ниже один и тот же файл — декоратор метода и декоратор класса — сначала под tsx, потом под node. Прогноз для второго запуска: какая строка напечатается первой?

прогон node 26.5.0 · 15 сентября 2026
Один файл: декоратор метода, декоратор класса и один вызов — под tsx
function logged(method: (this: unknown, title: string) => string, context: ClassMethodDecoratorContext) {
  console.log(`декоратор получил ${String(context.name)}`);
  return function (this: unknown, title: string) {
    console.log(`вызов ${String(context.name)}("${title}")`);
    return method.call(this, title);
  };
}

function controller(value: Function, context: ClassDecoratorContext) {
  console.log(`декоратор получил класс ${context.name}`);
}

console.log("перед классом");

@controller
class Notes {
  @logged
  add(title: string) {
    return `заметка «${title}»`;
  }
}

console.log(new Notes().add("хлеб"));
tsx deco.ts
прогон node 26.5.0 · 15 сентября 2026
Тот же файл, другая команда: node вместо tsx
function logged(method: (this: unknown, title: string) => string, context: ClassMethodDecoratorContext) {
  console.log(`декоратор получил ${String(context.name)}`);
  return function (this: unknown, title: string) {
    console.log(`вызов ${String(context.name)}("${title}")`);
    return method.call(this, title);
  };
}

function controller(value: Function, context: ClassDecoratorContext) {
  console.log(`декоратор получил класс ${context.name}`);
}

console.log("перед классом");

@controller
class Notes {
  @logged
  add(title: string) {
    return `заметка «${title}»`;
  }
}

console.log(new Notes().add("хлеб"));
node deco.ts

tsx отработал как песочница: декоратор метода раньше декоратора класса, оба до первого вызова. Внутри tsx — esbuild, и он переводит декораторы в обычный JavaScript, как tsc. А node не напечатал даже «перед классом»: файл не разобрался. Сравните с отказом на enum. Там Node узнавал синтаксис TypeScript и называл его — ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. Здесь SyntaxError без кода и без слова TypeScript, как на опечатке. Декоратор не относится к типам: это синтаксис самого JavaScript, и стирание оставляет его движку. А V8, движок Node, декораторов не знает.

И не он один. Стандартными эти декораторы называют по предложению в TC39. В марте 2022 года оно вышло на третью стадию — ту, где комитет считает решение готовым и рекомендует движкам его реализовать, а в марте 2023-го TypeScript 5.0 научился компилировать его без флагов. Но за четыре года ни один движок декораторы не выпустил: реализация в V8 остановлена, в SpiderMonkey она частичная, в JavaScriptCore её нет. 19 мая 2026 года комитет вернул предложение ступенью ниже, на стадию 2.7: замысел одобрен, но прежде чем снова звать движки, нужны полные тесты и исправленная спецификация. Туда же вернулось и соседнее предложение — о метаданных декораторов. Поэтому декоратор на практике всегда проходит через компилятор: в песочнице — tsc, под tsx — esbuild. Выполняет движок уже обычные функции.

Для части из этого следует правило запуска. Свой каркас этой главы стоит на стандартных декораторах: в песочнице его компилирует tsc, а когда он ответит на настоящем сокете, запускать его будет tsx, не node. У Nest своя система, а значит, и свой способ сборки.

  • Декоратор — обычная функция. @logged над методом — её вызов при объявлении класса: она получает метод и контекст, а возвращённая функция встаёт на место метода в прототипе, одна на все экземпляры.
  • Компилятор переписывает @ в статический блок класса: помощник зовёт декораторы начиная с ближайшего к методу и в замену принимает только функцию, а инициализаторы оставляет конструктору.
  • Контекст сообщает вид, имя, static, private, access, addInitializer и metadata, но не класс. Декораторы членов зовутся раньше декоратора класса, а выражения после @ — фабрики вроде Get("/:id") — вычисляются ещё раньше, сверху вниз.
  • addInitializer откладывает работу: для методов экземпляра — в конструктор до полей, для класса — на момент, когда статические поля уже заданы. this в инициализаторе типизирован как unknown, и свойство экземпляру ставят через Object.defineProperty.
  • Декораторов параметров в стандарте нет: @Body() даёт TS1206 и исчезает из вывода. Nest живёт на старой системе experimentalDecorators, где они есть, а свой каркас передаёт запрос одним объектом и объявляет зависимости списком.
  • Декоратор — синтаксис JavaScript, а не тип: node deco.ts падает с SyntaxError, tsx переписывает его через esbuild. Ни один движок декораторы не выпустил, и 19 мая 2026 года TC39 вернул предложение на стадию 2.7.

1/4Метод класса помечен @logged. Создано три экземпляра, у каждого метод вызван дважды. Сколько раз вызвана сама функция logged?

2/4Декоратор @Get("/:id") хочет записать маршрут прямо в класс-контроллер. Что из этого у него есть?

3/4В песочнице метод объявлен как create(@Body() dto), а фабрика Body() печатает строку. Что произойдёт?

4/4Почему node deco.ts падает на декораторе обычной SyntaxError, а не ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, как на enum?

Предупредить один раз

Фабрика deprecated(hint) должна помечать устаревший метод: на первом вызове напечатать «push устарел: используйте add», дальше молчать. Первым может оказаться любой экземпляр, а предупреждение нужно одно на всех. Сейчас декоратор возвращает метод как есть, и предупреждений нет вовсе.

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

Нужна обёртка, а флаг «уже предупредили» — в замыкании декоратора. Декоратор зовут один раз на метод, при объявлении класса, поэтому переменная warned общая для всех экземпляров.

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

Вывод:

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

Если помнить предупреждение для каждого экземпляра отдельно, строк «устарел» станет две — у home и у work. А пометь так ещё один метод, у него будет свой флаг: декоратор зовут на каждый метод отдельно.

Реестр контроллеров и статическое поле

Фабрика Controller(prefix) записывает контроллер в реестр вместе со списком маршрутов из статического поля routes. Сейчас печатается ["/notes: undefined"], а нужно ["/notes: GET /,GET /:id"]. Поле объявлено, декоратор читает его у того самого класса. Почему там пусто? Почините, не трогая класс.

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

Декоратор класса зовут раньше, чем заданы статические поля: сперва декораторы, потом значения полей. В момент вызова routes у класса ещё нет. Инициализатор класса выполняется после статических полей — туда чтение и переносится.

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

Вывод:

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

Внутри инициализатора читается value из замыкания, а не this. Это тот же класс, но this в инициализаторе класса описан общим типом конструктора, у которого поля routes нет, и компилятор возразил бы. Замыкание уже знает нужный тип из параметра.

Два декоратора над одним методом

Над методом title два декоратора: brackets берёт результат в квадратные скобки, short обрезает до пяти символов. Предскажите, что напечатает пример. Потом поменяйте две строки с @ местами и предскажите снова — и только после этого запускайте оба варианта.

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

Как написано — [купит], после перестановки — [купи.

Помощник компилятора применяет декораторы снизу вверх, начиная с ближайшего к методу. Первым short оборачивает исходный метод, затем brackets оборачивает то, что вернул short. Вызов идёт в обратную сторону, снаружи внутрь: обёртка brackets зовёт обёртку short, та — метод. Метод вернул «купить хлеб», short оставил «купит», brackets добавил скобки.

После перестановки внешней стала short: скобки добавились раньше, и из «[купить хлеб]» осталось пять символов — «[купи». Правило для чтения: декораторы над методом срабатывают как вложенные вызовы, записанные сверху вниз, — brackets(short(title)).