Decorators and Metadata
The standardised decorator model, what each decorator kind can do, metadata, and the narrow cases where a decorator beats a plain function call.
The standardised decorator model, what each decorator kind can do, metadata, and the narrow cases where a decorator beats a plain function call.
You find @Injectable() on a class and want to know what it
does. There is no call site to follow, no import chain to
inspect — the behaviour is registered somewhere at import time by
code that ran when the file loaded. Understanding it means
reading the framework.
Decorators attach behaviour without a visible call, which is their value and their cost. By the end of this lesson you will know what the standardised model provides, what each decorator kind can and cannot do, and the narrow cases where one beats an ordinary function.
There are two, and confusing them is the first obstacle.
Enabled by experimentalDecorators.
What Angular and older NestJS use. Different signature,
different capabilities, and it works with
emitDecoratorMetadata — which emits runtime type
information.
No flag at all.
The actual language feature, available in TypeScript 5 and in modern runtimes. What new code should use.
{
"compilerOptions": {
"target": "ES2022"
}
}No experimentalDecorators means the standard model. If you set
it, you get the legacy one, and the two are not
interchangeable — a decorator written for one will not work with
the other.
Everything below is the standard model.
A decorator is a function taking the thing being decorated and a context object:
function logged<This, Args extends unknown[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This>,
) {
const name = String(context.name);
return function (this: This, ...args: Args): Return {
console.log(`calling ${name}`);
return target.call(this, ...args);
};
}class PhotoService {
@logged
upload(file: Buffer): Promise<string> { ... }
}Returning a replacement is how a method decorator works — the same rest-tuple pattern from the generics lesson, which is what keeps the signature intact.
The context object is the interesting part:
context.kind // "method" | "class" | "field" | "getter" | ...
context.name // the member name
context.static // is it static
context.private // is it private
context.addInitializer(fn) // run at construction
context.access // { get, set } for reading the memberaddInitializer is what makes per-instance work possible — a
method decorator can register something to run when each
instance is built, which is how binding decorators work.
function cls(target: Function, ctx: ClassDecoratorContext) { }
function method(target: Function, ctx: ClassMethodDecoratorContext) { }
function getter(target: Function, ctx: ClassGetterDecoratorContext) { }
function field<T>(target: undefined, ctx: ClassFieldDecoratorContext) { }
function accessor<T>(target: ClassAccessorDecoratorTarget<unknown, T>,
ctx: ClassAccessorDecoratorContext) { }Two are worth calling out.
Field decorators receive undefined, not the value — fields
have no value at decoration time. They return an initialiser
instead:
function defaultTo<T>(value: T) {
return function (_: undefined, context: ClassFieldDecoratorContext) {
return function (initial: T): T {
return initial ?? value;
};
};
}
class Config {
@defaultTo(3000) port!: number;
}accessor is a new keyword that creates a getter, setter and
private backing field together, and gives a decorator access to
all three:
function validated<T>(check: (value: T) => boolean) {
return function (
target: ClassAccessorDecoratorTarget<unknown, T>,
context: ClassAccessorDecoratorContext,
): ClassAccessorDecoratorResult<unknown, T> {
return {
get() { return target.get.call(this); },
set(value: T) {
if (!check(value)) {
throw new RangeError(`invalid ${String(context.name)}: ${value}`);
}
target.set.call(this, value);
},
};
};
}
class Photo {
@validated((n: number) => n > 0) accessor size = 1;
}That is the closest thing to a validating property, and it is the case where a decorator genuinely reads better than the alternative.
The standard model has a metadata slot, and it is far more
limited than the legacy emitDecoratorMetadata:
function tag(value: string) {
return function (_: unknown, context: ClassDecoratorContext) {
context.metadata[Symbol.for("tag")] = value;
};
}
@tag("photos")
class PhotoService {}
const meta = PhotoService[Symbol.metadata];
console.log(meta?.[Symbol.for("tag")]); // "photos"The crucial limitation: there is no type information. The
legacy emitDecoratorMetadata emitted design:paramtypes,
which is how older dependency-injection frameworks resolved
constructor arguments from their types. The standard model does
not, because types are erased — the runtime-versus-compile-time
lesson, arriving where it is most inconvenient.
So a modern injection container needs explicit tokens:
class PhotoService {
constructor(@inject(STORAGE) private storage: Storage) {}
}That is more verbose and it is honest — the type was never going to be there.
Bad — a decorator doing what a function call does.
class ReportBuilder {
@memoize
@logged
@validated
@timed
build(input: Input): Report { ... }
}Good — composition you can read.
const build = memoize(timed("build", validateInput(buildReport)));Four stacked decorators mean the method's actual behaviour is
assembled from four files, applied bottom-up, in an order
nothing at the call site reveals. Debugging steps through four
wrappers. A reader wanting to know what build does must read
five things.
The composed version has the same behaviour with the order visible on one line, works on a plain function rather than requiring a class, and can be tested by calling it.
Three tests for whether a decorator earns its place:
Is it declarative metadata?
@Route("/photos"), @Column({ type: "text" }) — describing
something a framework reads. This is the genuine case.
Does it need per-instance setup?
addInitializer does something a wrapper cannot.
Is it the framework's convention?
Following it is right even where a plain function would work.
Otherwise, a higher-order function is clearer, testable, and does not require the thing to be a class.
TWO SYSTEMS - not interchangeable
experimentalDecorators the LEGACY model (Angular, older Nest)
+ emitDecoratorMetadata for type info
no flag the STANDARD model - use this
THE SHAPE
function dec(target, context) { return replacement; }
context.kind / name / static / private
context.addInitializer(fn) per-instance setup
context.access { get, set }
<This, Args extends unknown[], Return> preserves the signature
FIVE KINDS
class the constructor
method the function; return a replacement
getter/setter
field target is UNDEFINED - return an initialiser
accessor a new keyword: getter + setter + backing field,
all three available - the best case for validation
METADATA
context.metadata[symbol] = value
Class[Symbol.metadata]
NO TYPE INFORMATION - types are erased
legacy emitDecoratorMetadata had design:paramtypes; this does not
so modern DI needs explicit tokens: @inject(STORAGE)
WHEN A DECORATOR EARNS IT
declarative metadata a framework reads @Route, @Column
per-instance setup via addInitializer
the framework's own convention
otherwise a higher-order function: order visible, testable,
works on plain functions
four stacked decorators = behaviour assembled from four files,
applied bottom-up, invisible at the call site
TIMING
decorators run at class DEFINITION time, i.e. on import
no I/O in the decorator body - put it in the wrapperYou can now write decorators against the standard model, know what each kind receives, and know that runtime type metadata is gone for good. The judgement is the same one this course keeps returning to: a decorator hides the call, and hiding it is worth it for declarative metadata and rarely for behaviour.
Next is Assertion Functions and satisfies, which returns to narrowing with the tools that let you teach the checker something it cannot infer — and the one that checks a value without widening it.
Before you move on, take a decorator in a codebase you work on and write out what it does as a plain function wrapping the method. If the result is clearer, that is the answer for new code; if it is not, you have found a case where the decorator was earning its place.