Skip to content

Decorators

A decorator is a function that wraps or observes a class or a class member, letting you add behavior declaratively with @name syntax. There are two incompatible designs, and it matters which you mean:

  • Legacy decorators — the old, experimental design enabled by experimentalDecorators in tsconfig. Still used by NestJS and Angular, so the flag remains a supported opt-in in TypeScript 7. Different signatures, relies on reflect-metadata, and depends on tsc’s own emit — a build that only strips types (--erasableSyntaxOnly, Node’s type stripping, most bundlers’ fast paths) cannot run them.
  • Stage-3 decorators — the standardized ECMAScript design, supported natively since TypeScript 5.0 with no special flag and unchanged through TypeScript 7. Because the syntax is plain ECMAScript rather than a TypeScript extension, it is the one to learn for new code.

This lesson covers stage-3 decorators.

TypeScript 5.5 tightened what may follow the @: only an identifier, a dotted name, or a call. Anything else must be parenthesized to parse at all:

// @expect-error TS1497 TS2660
class C {
@super.decorate
method() {}
}

Parentheses only fix the parse error (TS1497). They do not make super valid there: a decorator expression is evaluated once, at class-definition time, in the lexical scope outside the class body — before any member (and its super binding) exists. So on a real subclass, the parenthesized form still fails, now with only the semantic error:

// @expect-error TS2660
class Base {
static decorate(originalMethod: any, context: ClassMethodDecoratorContext) {}
}
class C extends Base {
@(super.decorate)
method() {}
}

Parentheses exist for genuinely non-identifier decorator expressions — a ternary choosing between two decorators, a call, an index into a table of decorators:

declare function a(originalMethod: any, context: ClassMethodDecoratorContext): any;
declare function b(originalMethod: any, context: ClassMethodDecoratorContext): any;
declare const cond: boolean;
class C {
@(cond ? a : b) // ✅ parentheses make a non-identifier expression a valid decorator
method() {}
}

A method decorator receives the original method and a context object, and returns a replacement (or nothing). Here is a @logged decorator that wraps a method to log its calls:

function logged(originalMethod: any, context: ClassMethodDecoratorContext) {
const name = String(context.name);
function replacement(this: any, ...args: any[]) {
console.log(`calling ${name}`);
return originalMethod.call(this, ...args);
}
return replacement;
}
class Service {
@logged
fetch(id: number) {
return `item ${id}`;
}
}
new Service().fetch(1); // logs "calling fetch", then returns "item 1"

The context object is the heart of the new design. It tells the decorator what it is decorating — context.kind ("method", "field", "getter", …), context.name, context.static, context.private, and an addInitializer hook for setup work.

A method decorator, actually running in the browser

function logged(originalMethod: any, context: ClassMethodDecoratorContext) {
const name = String(context.name);
function replacement(this: any, ...args: any[]) {
console.log(`calling ${name}`);
return originalMethod.call(this, ...args);
}
return replacement;
}
class Service {
@logged
fetch(id: number) {
return `item ${id}`;
}
}
console.log(new Service().fetch(1));
Open in TS Playground

Click Check types: zero diagnostics, same as any fence in this lesson. Click Run, though, and — unlike node --experimental-strip-types — it actually executes and prints calling fetch then item 1. The in-browser playground’s Run button uses the TypeScript compiler’s own transpileModule (currently TypeScript 5.9.3 there, versus 7.0.2 for type-checking elsewhere in this course), which down-levels stage-3 decorators into plain ES2022 helper functions (__esDecorate, __runInitializers) rather than emitting the raw @ syntax verbatim. That compiled output is ordinary JavaScript no @ syntax remains, so it runs anywhere — including inside this playground’s sandboxed iframe, and including Node itself if you compiled ahead of time with tsc instead of asking Node to strip types on the fly. node --experimental-strip-types fails only because it does the second, much shallower thing: it deletes type syntax textually without running a real transform, and decorators are syntax it was never built to remove.

Decorator kindReceivesTypical use
Classthe class, contextregister it, wrap the constructor
Methodthe method, contextwrap/replace behavior (logging, timing)
Accessorthe getter/setter, contextintercept reads/writes
Fieldundefined, contextreturn an initializer to transform the initial value

A field decorator is a little different — it does not receive a value to wrap; it can return an initializer function that transforms the field’s initial value when an instance is created.

An accessor decorator targets an accessor-keyword auto-accessor (from the Classes, Deep lesson) and can wrap both the generated get and set. Its target isn’t a single function, but a { get, set } pair:

function logged(
value: ClassAccessorDecoratorTarget<unknown, number>,
context: ClassAccessorDecoratorContext,
) {
return {
get(this: unknown) {
const v = value.get.call(this);
console.log(`get ${String(context.name)} -> ${v}`);
return v;
},
set(this: unknown, newValue: number) {
console.log(`set ${String(context.name)} <- ${newValue}`);
value.set.call(this, newValue);
},
};
}
class Counter {
@logged
accessor count = 0;
}
new Counter().count = 5;

This compiles cleanly and, like the class decorator earlier in this lesson, throws the same SyntaxError: Invalid or unexpected token under node --experimental-strip-types — decorator syntax, not the specific kind of thing being decorated, is what Node’s stripper can’t parse. One detail worth naming precisely: that error is not the ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX code Node uses for TypeScript syntax it recognizes but deliberately refuses (parameter properties, enum, namespace — the erasable-syntax violations from the Classes, Deep lesson). Decorators aren’t TypeScript syntax at all — they’re a JavaScript proposal Node’s stripper was never built to remove, so the parser fails on the raw @ token itself, the same generic way it would fail on any unrecognized character. The two failure modes look similar but come from different places: one is Node’s stripper saying “I see this, and it isn’t allowed here”; the other is Node’s underlying JavaScript parser saying “I don’t know what this token is at all.”

Every stage-3 decorator context carries a metadata object (context.metadata, TypeScript 5.2+) — a plain object shared by every decorator applied to the same class, meant for decorators to stash information on, readable later off the class itself via the well-known Symbol.metadata:

function track(value: undefined, context: ClassFieldDecoratorContext) {
const metadata = context.metadata as Record<string, unknown>;
metadata[context.name as string] = true;
}
class Config {
@track
host = "localhost";
}
console.log(Object.keys(Config[Symbol.metadata] ?? {}));

This compiles cleanly under this course’s settings (--lib esnext,dom,dom.iterable) — but Symbol.metadata is new enough that it is worth checking which lib actually ships it. Compiling the identical source with --lib es2025,dom instead of esnext fails:

$ node_modules/typescript/bin/tsc --ignoreConfig --noEmit --strict --target es2025 --lib es2025,dom --skipLibCheck metadata.ts
metadata.ts(10,27): error TS2550: Property 'metadata' does not exist on type 'SymbolConstructor'. Do you need to change your target library link. Do you need to change your target library? Try changing the 'lib' compiler option to 'esnext' or later.

As of TypeScript 7.0.2, Symbol.metadata’s typing still lives only in the esnext proposal lib, not yet in the versioned es2025 lib — even though the language feature itself has been stable since TypeScript 5.2. Any project targeting decorator metadata specifically needs "lib": [..., "esnext"] (or a later versioned lib once one ships it), not just a recent target.

To make a decorator configurable, write a function that returns a decorator — a decorator factory. The @name(args) call runs the factory, and its return value is the actual decorator.

function logged(label: string) { // factory: takes config
return function (originalMethod: any, context: ClassMethodDecoratorContext) {
return function (this: any, ...args: any[]) {
console.log(`[${label}] ${String(context.name)}`);
return originalMethod.call(this, ...args);
};
};
}
class Api {
@logged("http")
get() { /* ... */ }
}

Why NestJS and Angular still reach for the legacy design

Section titled “Why NestJS and Angular still reach for the legacy design”

Stage-3’s context.metadata (above) is deliberately minimal: it’s an empty object decorators populate themselves, with no automatic connection to anything the compiler already knows about the class. Dependency-injection frameworks need something stage-3 doesn’t provide at all: the declared type of every constructor parameter, so the container can look up “this parameter is typed Logger, go construct or fetch a Logger” without the developer writing that mapping by hand. That specific piece of information — emitted automatically, with no decorator having to compute it — is exactly what emitDecoratorMetadata (paired with experimentalDecorators) produces, and stage-3 decorators have no equivalent for it.

Compiling the same class both ways makes the gap concrete. Under stage-3 (this course’s default settings — no extra flags):

function Injectable(): ClassDecorator {
return (target) => {};
}
class Logger {}
@Injectable()
class UserService {
logger: Logger;
constructor(logger: Logger) {
this.logger = logger;
}
}

emits (tsc --target es2025 --module esnext, no extra flags):

function Injectable() {
return (target) => { };
}
class Logger {
}
let UserService = (() => {
let _classDecorators = [Injectable()];
let _classDescriptor;
let _classExtraInitializers = [];
let _classThis;
var UserService = class {
static { _classThis = this; }
static {
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
__esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
UserService = _classThis = _classDescriptor.value;
if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
__runInitializers(_classThis, _classExtraInitializers);
}
logger;
constructor(logger) {
this.logger = logger;
}
};
return UserService = _classThis;
})();

Nowhere in that output is the fact that logger’s declared type is Logger — the emitted constructor(logger) is just a plain parameter name, exactly as erasure always throws types away. Compiling the identical source with --experimentalDecorators --emitDecoratorMetadata instead:

$ node_modules/typescript/bin/tsc --ignoreConfig --target es2025 --module esnext --experimentalDecorators --emitDecoratorMetadata --outDir out legacy.ts
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
function Injectable() {
return (target) => { };
}
class Logger {
}
let UserService = class UserService {
logger;
constructor(logger) {
this.logger = logger;
}
};
UserService = __decorate([
Injectable(),
__metadata("design:paramtypes", [Logger])
], UserService);

__metadata("design:paramtypes", [Logger]) is the piece stage-3 has no way to express: the compiler itself, knowing logger: Logger from the source, emits the real Logger class value into the output, retrievable at runtime with Reflect.getMetadata("design:paramtypes", UserService) (via the separate reflect-metadata package, which supplies Reflect.metadata/Reflect.getMetadata). That is the exact mechanism NestJS’s @Injectable() and Angular’s dependency injector are built on: decorate a class, then ask the runtime “what are this constructor’s parameter types?” and get a real answer — something no amount of stage-3 context.metadata bookkeeping can provide, because stage-3 metadata is only ever what a decorator chooses to write into it by hand, never something the compiler populates automatically from the type system.

Keep two independent questions separate whenever decorator code doesn’t behave the way you expect: “does the compiler need to transform this syntax into something else?” (erasable-syntax-only settings care about this, and decorators pass — they’re standard ECMAScript, nothing to transform for type-stripping purposes) and “does the JavaScript engine actually running this code understand the syntax at all?” (this is what fails under node --experimental-strip-types, and it’s a completely different failure — a raw parse error, not a rejected-but-recognized-TypeScript-construct error). The in-browser playground’s Run button sidesteps the second question entirely by real-transpiling first, which is also exactly how a production build using tsc (or any bundler with a real TypeScript transform, not just a stripper) ships decorator-using code today.

DesignBenefitCost
Stage-3 decoratorsStandard ECMAScript, no flag, context object is rich and well-typed, works with --erasableSyntaxOnlyCan’t run under a strip-only pipeline (Node’s type stripping) without a real transpile step first; context.metadata has no automatic type information
Legacy decorators (experimentalDecorators)emitDecoratorMetadata bakes real constructor parameter types (design:paramtypes) into the output — the foundation of NestJS/Angular-style DIExperimental, different signatures from stage-3, requires reflect-metadata, tied to tsc’s own emit (never works under a pure type-stripper)
accessor + accessor decoratorClean interception of both get and set through one declarationOnly applies to accessor-keyword members, not plain get/set pairs written by hand
  • If code type-checks under --erasableSyntaxOnly, it will run under Node’s type stripping. Decorators are the sharpest counterexample in this course: they pass --erasableSyntaxOnly cleanly (nothing about them needs compiler-side transformation) and still throw a plain SyntaxError under node --experimental-strip-types, because that flag only strips type annotations — it does not implement or transpile any JavaScript-level proposal V8 lacks.
  • The SyntaxError decorators throw under Node is the same kind of error as an unerasable TypeScript construct (parameter properties, enum). It isn’t — those get Node’s specific ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, a deliberate rejection of recognized-but-disallowed TypeScript syntax. Decorators fail with a generic parse error because the stripper’s parser doesn’t recognize the @ token as anything at all.
  • context.metadata in a stage-3 decorator gives you the same thing design:paramtypes gives a legacy one. It doesn’t — context.metadata is an empty object a decorator must populate itself; design:paramtypes is the compiler automatically emitting a constructor’s real, declared parameter types with zero decorator code required, which is precisely the capability stage-3 decorators were not designed to replace.
  • A stage-3 @Injectable()-style decorator that expects to read constructor parameter types off context or context.metadata. This will not work — stage-3’s context object never contains type information, because types are erased before any decorator ever runs. Reading real parameter types at runtime requires experimentalDecorators + emitDecoratorMetadata and the legacy decorator shape; generated code mixing “stage-3 syntax” with “reads design:paramtypes-style metadata” is combining two designs that don’t interoperate.
  • A @Component-style class decorator copied from a NestJS or Angular example into a project that doesn’t have experimentalDecorators enabled. The decorator syntax itself will often still compile (stage-3 accepts a wide range of decorator function shapes), but any reflection the decorator relies on (Reflect.getMetadata) will silently return undefined, since no design:paramtypes metadata was ever emitted without the flag.

🐛 Spot the bug

function Injectable(): ClassDecorator {
return (target) => {};
}
@Injectable()
class Logger {}
@Injectable()
class UserService {
logger: Logger;
constructor(logger: Logger) {
this.logger = logger;
}
}
// Somewhere in a hand-rolled DI container:
function resolve(target: any) {
const paramTypes = Reflect.getMetadata("design:paramtypes", target) ?? [];
console.log(paramTypes);
}
resolve(UserService);
Show the answer

This project has no experimentalDecorators/emitDecoratorMetadata in its tsconfig.json — the decorators compile fine as ordinary stage-3 decorators, but resolve(UserService) logs an empty array (or throws, if reflect-metadata’s polyfill for Reflect.getMetadata was never imported at all), never [Logger]. design:paramtypes metadata is only ever emitted by the legacy decorator pipeline, gated behind experimentalDecorators and emitDecoratorMetadata together — stage-3 decorators, no matter how they’re written, never populate it, because stage-3’s context.metadata is an opt-in bag decorators must fill by hand, not something the compiler auto-populates from the type system. The fix is either to actually enable both legacy flags project-wide (and accept the legacy decorator model everywhere), or to abandon reflection-based DI and pass dependencies explicitly — the constructor-parameter-type trick this code is trying to use simply has no stage-3 equivalent.

Which decorator design should new TypeScript code use?
What does the second argument to a stage-3 decorator (the context) provide?
This compiles cleanly under --erasableSyntaxOnly and --strict. Why does it still throw a SyntaxError when run with node --experimental-strip-types?
function logged(value: any, context: ClassMethodDecoratorContext) {
  return value;
}
class Service {
  @logged
  fetch() { return "ok"; }
}
What is different about the SyntaxError a decorator throws under Node's type-stripping, compared to the ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX error a parameter property throws there?
Why does the in-browser TSPlayground's "Run" button successfully execute code using stage-3 decorators, even though node --experimental-strip-types cannot?
Compiling this with plain stage-3 settings, versus with --experimentalDecorators --emitDecoratorMetadata, produces very different output. Why do NestJS and Angular still require the legacy pairing instead of stage-3 alone?
function Injectable(): ClassDecorator {
  return (target) => {};
}
class Logger {}
@Injectable()
class UserService {
  logger: Logger;
  constructor(logger: Logger) { this.logger = logger; }
}