02 · Decorators¶
Decorators let you attach reusable behavior to a class, method, accessor,
or field by writing @something right above its declaration — logging,
retry logic, validation, dependency injection, and ORMs like TypeORM all
lean on this mechanism. This module uses TypeScript's modern standard
decorators (the TC39 Stage 3 proposal, TypeScript's default since 5.0)
— no experimentalDecorators flag required, and they check cleanly
under --strict.
Standard decorators vs. legacy experimentalDecorators
Older TypeScript code (and some frameworks, like older NestJS/Angular
versions) uses a different, earlier decorator implementation enabled
by "experimentalDecorators": true in tsconfig.json. The syntax
looks similar but the underlying types differ. If you're adding
decorators to an existing project and these examples don't quite
match what you see, check for that flag first.
Method decorators¶
A method decorator receives the original method and a context object
describing it, and returns a replacement function:
function logged<This, Args extends unknown[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
const methodName = String(context.name);
return function (this: This, ...args: Args): Return {
console.log(`Calling ${methodName} with`, args);
const result = target.call(this, ...args);
console.log(`${methodName} returned`, result);
return result;
};
}
class Calculator {
@logged
add(a: number, b: number): number {
return a + b;
}
}
const calc = new Calculator();
calc.add(2, 3);
The type parameters (This, Args, Return) are what keep this
generic: logged works on any method, on any class, with any argument
list, and the decorated method keeps its original call signature from
the outside — callers of calc.add(2, 3) see nothing different.
Decorator factories — decorators that take arguments¶
A decorator itself can't take extra arguments directly, but a function that returns a decorator can. This is a decorator factory, and it's the pattern behind almost every configurable decorator you'll encounter:
function retry(times: number) {
return function <This, Args extends unknown[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
const methodName = String(context.name);
return function (this: This, ...args: Args): Return {
let lastError: unknown;
for (let attempt = 1; attempt <= times; attempt++) {
try {
return target.call(this, ...args);
} catch (err) {
lastError = err;
console.log(`${methodName} attempt ${attempt} failed`);
}
}
throw lastError;
};
};
}
class FlakyService {
private attempts = 0;
@retry(3)
fetchData(): string {
this.attempts += 1;
if (this.attempts < 3) {
throw new Error(`not ready (attempt ${this.attempts})`);
}
return "data!";
}
}
const service = new FlakyService();
console.log(service.fetchData());
@retry(3) calls retry(3) first, which returns the real method
decorator with times captured in its closure — that's the whole trick
behind "a decorator with arguments."
Class decorators¶
A class decorator receives the class constructor itself and can return a new constructor to replace it entirely — commonly used to wrap construction with extra setup:
function sealed<T extends new (...args: any[]) => object>(
target: T,
context: ClassDecoratorContext<T>
): T {
return class extends target {
constructor(...args: any[]) {
super(...args);
Object.seal(this);
}
};
}
@sealed
class Point {
x = 0;
y = 0;
}
const p = new Point();
p.x = 10;
console.log(p.x);
console.log(Object.isSealed(p)); // true -- @sealed locked the instance shape
Object.seal is a runtime guarantee (it prevents adding new properties),
separate from TypeScript's compile-time structural checks — @sealed
demonstrates a decorator enforcing something at runtime that the type
system alone can't.
Auto-accessor decorators¶
Fields declared with the accessor keyword get an implicit private
backing field plus a get/set pair, which a decorator can intercept —
useful for logging, validation, or change-tracking on a property:
function logAccess<This, Value>(
target: ClassAccessorDecoratorTarget<This, Value>,
context: ClassAccessorDecoratorContext<This, Value>
): ClassAccessorDecoratorResult<This, Value> {
const fieldName = String(context.name);
return {
get(this: This): Value {
const value = target.get.call(this);
console.log(`reading ${fieldName}:`, value);
return value;
},
set(this: This, value: Value): void {
console.log(`writing ${fieldName}:`, value);
target.set.call(this, value);
},
};
}
class Counter {
@logAccess
accessor count = 0;
}
const counter = new Counter();
counter.count = 5;
console.log(counter.count);
Note accessor count = 0, not count = 0 — plain fields can only be
decorated in a much more limited way (they don't support intercepting
reads/writes like this); accessor is what unlocks the full
get/set decorator shape.
A trap: decorator evaluation order surprises people¶
Decorators run bottom-up when a declaration has more than one, but the factory calls that produce them run top-down:
function first() {
console.log("first: factory evaluated");
return function (target: unknown, context: ClassMethodDecoratorContext) {
console.log("first: decorator applied");
};
}
function second() {
console.log("second: factory evaluated");
return function (target: unknown, context: ClassMethodDecoratorContext) {
console.log("second: decorator applied");
};
}
class Demo {
@first()
@second()
method() {}
}
// second: factory evaluated
// first: factory evaluated
// second: decorator applied
// first: decorator applied
If you stack decorators (logging, then validation, then caching, for example) and the combined behavior looks backwards, this ordering — not a bug in your decorator — is almost always why.
How It Actually Works¶
Unlike almost every other TypeScript-only construct, decorators are not type-erased — they compile to real function calls that execute at class-definition time, not at instantiation time. @log class Foo {} compiles (in the legacy experimentalDecorators model) to something like Foo = __decorate([log], Foo), where __decorate is a runtime helper the compiler emits once per file that uses decorators, and it calls your decorator function immediately as the class declaration is evaluated, passing it the constructor (or property descriptor) to optionally replace or wrap. This is why decorator evaluation order is a real runtime sequencing question, not a type-checking abstraction: decorators run bottom-to-top within a single declaration, and property/method decorators run before class decorators, because that mirrors the order the underlying descriptors actually become available during class construction.
TypeScript's decorator support went through a genuine breaking change: experimentalDecorators implements TypeScript's own pre-standard proposal (the __decorate/__param/__metadata helpers, still widely used by frameworks like Angular and NestJS), while newer TypeScript versions also support the stage-3 ECMAScript decorators standard, which has a different runtime calling convention (decorators receive a context object, not raw property descriptors) and compiles to different emitted helper code — the two are not interchangeable, and mixing decorator syntax written for one model against a tsconfig targeting the other produces confusing runtime failures rather than compile errors, because both are syntactically valid decorator positions to the parser.
reflect-metadata (paired with emitDecoratorMetadata) is where decorators intersect with the type system in an unusual way: the compiler, when this flag is set, emits calls that store a runtime-accessible representation of a parameter or property's declared type (as a constructor-function value, like String or Number, not the full structural TypeScript type) as metadata on the class — this is the one narrow case where type information does leak into runtime-inspectable form, and it's exactly what dependency-injection frameworks use to look up which class to construct for a given constructor parameter, since ordinary generic-parameter erasure would otherwise make that impossible.
Cheat sheet¶
| Decorator kind | Signature receives | Typical use |
|---|---|---|
| Method | (target, context: ClassMethodDecoratorContext) |
Logging, retry, memoization |
| Class | (target, context: ClassDecoratorContext) |
Sealing, registration, mixins |
| Accessor | (target, context: ClassAccessorDecoratorContext) |
Validation, change tracking |
| Field | (value, context: ClassFieldDecoratorContext) |
Default-value transforms |
| Decorator factory | function that returns any of the above | Configurable decorators, e.g. @retry(3) |
| Stacking order | bottom decorator's factory runs first; decorators apply bottom-up | Matters when order affects behavior |
Exercise¶
Write a method decorator factory @memoize() that caches a method's
return value keyed by JSON.stringify(args), so repeated calls with the
same arguments skip re-running the method body (log something inside the
method so you can see it only executes once per distinct argument list).
Apply it to a deliberately slow method (e.g. one with a for loop doing
extra work) and call it three times with two different argument sets to
prove the cache hits.