Стандартные декораторы: что они делают на самом деле
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Контроллер Nest выглядит как класс, обвешанный строками с @: @Controller("notes") над классом, @Get(":id") над методом, @Body() перед параметром. Похоже на настройку, которую кто-то читает со стороны. На деле за каждой такой строкой стоит вызов обычной функции в момент, когда объявляется класс. В этой главе мы соберём сердце Nest сами — декораторы, метаданные, контейнер — и начнём с самого @: что получает функция-декоратор, что может вернуть, когда её зовут и почему @Body() в параметре стандартный TypeScript не пропускает вовсе.
Декоратор — это функция. Строка @logged над методом просит: когда класс будет объявлен, позови logged, передай ей сам метод и объект с описанием места — контекст, — а если она вернёт новую функцию, поставь её вместо метода. Декоратор ниже возвращает обёртку: она печатает каждый вызов и передаёт работу исходному методу. Прогноз: в каком порядке появятся строки, сколько раз напечатается «декоратор получил 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 — в объявлении метода, в конструкторе или где-то ещё?
Декоратор зовут из блока static { … }. Это статический блок класса: код, который выполняется один раз, пока класс объявляется, — отсюда и строка «декоратор получил add» до первого new. __esDecorate — помощник из начала файла. Он берёт метод из прототипа, зовёт с ним декораторы из списка [logged], начиная с последнего, то есть ближайшего к методу, и возвращённую функцию ставит на место метода. Принять он готов только функцию или undefined: на строку или число помощник отвечает TypeError: Function expected, а компилятор ещё до запуска — ошибкой типа.
Четвёртый аргумент — тот самый контекст, записанный литералом: kind: "method", имя, static, private, объект access с функциями чтения и metadata. Объект для метаданных компилятор создаёт, только если в движке есть Symbol.metadata. В песочнице его нет, поле получает undefined, и что с этим делать, разберёт следующая статья. Метода addInitializer в литерале нет: помощник добавляет его в контекст сам, а функции, переданные в него, складывает в массив _instanceExtraInitializers. Этот массив выполняет конструктор — к нему вернёмся через два раздела.
Декоратор можно поставить на сам класс и на его метод, геттер, сеттер или поле, статические и приватные тоже: тип DecoratorContext объединяет контексты всех этих видов. Декоратор where ничего не меняет, только печатает, что узнал о месте. Прогноз: в каком порядке выйдут шесть строк и совпадёт ли он с порядком в тексте класса?
Порядок не текстовый. Первым вызван статический 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") — не декоратор, а фабрика: обычная функция, которая запоминает путь и возвращает настоящий декоратор. Обе фабрики ниже печатают, когда их вычислили, а декораторы, которые они вернули, — когда их позвали. Прогноз: в каком порядке выйдут шесть строк?
Сначала три «вычислено» — сверху вниз, как написано, начиная с класса. Потом декораторы методов и последним декоратор класса, в порядке из прошлого раздела. Это два разных шага. Выражения после @ вычисляются, пока читается объявление, а декораторы зовутся, когда методы уже определены. Для фабрики это значит, что аргументы — путь, роль, схема ввода — известны заранее, а пользоваться ими декоратор будет позже.
Теперь посмотрите, чего не хватает маршрутизатору. Get("/:id") знает путь и имя метода, byId, но не знает, что метод лежит в NotesController с префиксом /notes: класса в его контексте нет. Каркасу же нужна запись «GET /notes/:id → NotesController.byId», которую можно найти по классу, когда придёт запрос. Где декоратор метода оставляет такую запись и как её достать — вопрос следующей статьи.
Декоратор метода работает, когда экземпляров ещё нет, а иногда работа нужна именно в экземпляре. Классический случай — метод, положенный в переменную: const increment = counter.increment теряет this. Лечение из первой части — bind, но привязать метод можно только к конкретному объекту. Для этого у контекста есть addInitializer: переданная в него функция выполнится позже, и для члена экземпляра this в ней — создаваемый объект. Декоратор bound кладёт в экземпляр привязанную копию метода. Прогноз: сработает ли оторванный increment и в каком порядке окажутся ключи экземпляра?
Оторванный метод работает: 1 2. Инициализатор записал в экземпляр собственное свойство increment — привязанную копию, — и оно заслонило метод прототипа. А порядок ключей показывает, когда это случилось: increment появился раньше count. Инициализаторы методов выполняются до полей. В выводе из второго раздела полей не было, и __runInitializers стоял в конструкторе. У Counter поле есть, и компилятор вписывает вызов прямо в его инициализатор, перед значением: count = (__runInitializers(this, _instanceExtraInitializers), 0). Прочитать поле в таком инициализаторе нельзя: его ещё нет.
Под примером компилятор возражает: «Объект имеет тип "Неизвестный"», в терминале это TS2571. Запуску это не мешает, но причина поучительна. Декоратор — отдельная функция, её можно поставить в любой класс, и ClassMethodDecoratorContext без аргументов типа не знает, в какой: this в инициализаторе описан как unknown. Параметр типа, как в обобщённой функции, тут не спасёт. С ClassMethodDecoratorContext<This> в инициализаторе окажется This, но внутри декоратора это по-прежнему какой угодно тип без индексной сигнатуры, и запись по произвольному имени компилятор не пропустит, только ошибка сменится на TS7053. Неизвестный объект нужно не индексировать, а отдать функции, которая принимает любой:
Ноль диагностик, хотя this остался неизвестным. Object.defineProperty описан так, что первым аргументом берёт что угодно, а вторым — любое имя свойства, строку или символ. Поэтому ему подходят и this типа unknown, и context.name, а привязанная копия встаёт в экземпляр так же, как в Counter.
Когда выполняется инициализатор, зависит от того, что украшено. Проверено и в песочнице, и под tsx:
| Декоратор стоит на | Инициализатор выполняется | this в нём |
|---|---|---|
| методе или геттере экземпляра | в конструкторе, до полей | экземпляр |
| поле экземпляра | сразу после того, как это поле получило значение | экземпляр |
| статическом методе или геттере | при объявлении класса, до статических полей | класс |
| классе | после объявления класса, когда статические поля уже заданы | класс |
В Nest тело запроса попадает в метод контроллера через декоратор параметра: create(@Body() dto). Попробуем то же в песочнице. Body() — фабрика, которая сообщает о своём вызове. Прогноз: напечатает ли пример «Body() вызван»?
Компилятор отвечает «Декораторы здесь недопустимы», в терминале это 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. Прогноз для второго запуска: какая строка напечатается первой?
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("хлеб"));
перед классом
декоратор получил add
декоратор получил класс Notes
вызов add("хлеб")
заметка «хлеб»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("хлеб"));
<пример>/deco.ts:15
@controller
^
SyntaxError: Invalid or unexpected token
at <внутренние кадры node>
Node.js v<версия>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», дальше молчать. Первым может оказаться любой экземпляр, а предупреждение нужно одно на всех. Сейчас декоратор возвращает метод как есть, и предупреждений нет вовсе.
Нужна обёртка, а флаг «уже предупредили» — в замыкании декоратора. Декоратор зовут один раз на метод, при объявлении класса, поэтому переменная warned общая для всех экземпляров.
Вывод:
Если помнить предупреждение для каждого экземпляра отдельно, строк «устарел» станет две — у home и у work. А пометь так ещё один метод, у него будет свой флаг: декоратор зовут на каждый метод отдельно.
Реестр контроллеров и статическое поле
Фабрика Controller(prefix) записывает контроллер в реестр вместе со списком маршрутов из статического поля routes. Сейчас печатается ["/notes: undefined"], а нужно ["/notes: GET /,GET /:id"]. Поле объявлено, декоратор читает его у того самого класса. Почему там пусто? Почините, не трогая класс.
Декоратор класса зовут раньше, чем заданы статические поля: сперва декораторы, потом значения полей. В момент вызова routes у класса ещё нет. Инициализатор класса выполняется после статических полей — туда чтение и переносится.
Вывод:
Внутри инициализатора читается value из замыкания, а не this. Это тот же класс, но this в инициализаторе класса описан общим типом конструктора, у которого поля routes нет, и компилятор возразил бы. Замыкание уже знает нужный тип из параметра.
Два декоратора над одним методом
Над методом title два декоратора: brackets берёт результат в квадратные скобки, short обрезает до пяти символов. Предскажите, что напечатает пример. Потом поменяйте две строки с @ местами и предскажите снова — и только после этого запускайте оба варианта.
Как написано — [купит], после перестановки — [купи.
Помощник компилятора применяет декораторы снизу вверх, начиная с ближайшего к методу. Первым short оборачивает исходный метод, затем brackets оборачивает то, что вернул short. Вызов идёт в обратную сторону, снаружи внутрь: обёртка brackets зовёт обёртку short, та — метод. Метод вернул «купить хлеб», short оставил «купит», brackets добавил скобки.
После перестановки внешней стала short: скобки добавились раньше, и из «[купить хлеб]» осталось пять символов — «[купи». Правило для чтения: декораторы над методом срабатывают как вложенные вызовы, записанные сверху вниз, — brackets(short(title)).