Files
What you'll learn
- Write class and method decorators, and decorator factories that take arguments
- Explain when decorators run and in what order, and what each kind receives
- Wrap a method through its property descriptor without losing this or its return value
Decorators
A NestJS controller is mostly decorators: @Controller('cats') on the class, @Get(':id') on a method, @Param('id') on a parameter. They look like configuration, and it is tempting to treat them as magic. They are not: a decorator is an ordinary function that TypeScript calls when the class is defined, handing it the class, the method or the parameter it sits on. What Nest's decorators do with that is simple: they write down facts ("this class answers under /cats", "this method answers GET :id") for the framework to read later. This lesson writes the same kind of decorators, which is the surest way to stop finding them mysterious.
Two flavours, one of them Nest's
TypeScript has two decorator systems. The one NestJS is built on is enabled by the experimentalDecorators compiler option (with emitDecoratorMetadata, lesson 8); both are on in this editor, as in every Nest project. TypeScript 5 also supports the newer standard decorators, with different signatures, used when that option is off. Everything here is the experimental kind.
What each kind receives
| Kind | Called with | Return |
|---|---|---|
| class | the class (its constructor function) | nothing, or a replacement class |
| method | the prototype, the method's name, its property descriptor | nothing, or a replacement descriptor |
| property | the prototype, the property's name | nothing |
| parameter | the prototype, the method's name, the parameter's index | nothing |
For a method, target is the class's prototype, the object its instances share, so the class itself is target.constructor. The descriptor is the object that holds the method: descriptor.value is the function.
Factories
@Get(':id') has an argument, so Get is not itself the decorator; it is a factory that returns one:
function Tag(label: string): ClassDecorator {
return (target) => {
tags.set(target, label);
};
}
@Tag('internal')
class AuditService {}
Tag('internal') runs first and returns the decorator, which TypeScript then applies to the class.
When they run, and in what order
Decorators run once, when the class definition is evaluated, which is when its module is imported: before any instance exists and before any method is called. Within a class, the member decorators run first, in the order the members are declared, and the class decorator last. On one member with several decorators, the factories are evaluated top to bottom and the decorators applied bottom to top, so the one nearest the method wraps it first.
That timing is why a decorator can only record things about a method, or replace it; anything that should happen per call has to be put inside a wrapper.
Keeping facts per class
A decorator needs somewhere to write. A WeakMap keyed by the class works well: each class gets its own entry, and a class that is no longer used can be garbage-collected with its entry. (Nest writes into the class's metadata instead, which lesson 8 is about.)
Wrapping a method
A method decorator can replace descriptor.value with a new function that does something around the original:
function Timed(times: number[]) {
return (target: object, key: string | symbol, descriptor: PropertyDescriptor): void => {
const original = descriptor.value;
descriptor.value = function (this: unknown, ...args: unknown[]) {
const started = performance.now();
try {
return original.apply(this, args);
} finally {
times.push(performance.now() - started);
}
};
};
}
Two details carry the weight. The wrapper is a function, not an arrow, so it receives the this of the call, the instance; and it calls the original with original.apply(this, args), passing that this on and returning the result. An arrow function, or a plain original(...args), loses the instance, and the method's first this.something fails.
Your task
The work is in routing.ts; the spec decorates four controllers with your decorators.
@Controller(prefix)records the prefix for the class.@Get(path)and@Post(path)record a route for the method's class: the HTTP method, the path (''when none is given) and the method's name ashandler.routesOf(controller)answers every route of a controller in the order its methods were declared, the path being the prefix and the route's path joined by single slashes and starting with one (cats+:idis/cats/:id,''+healthis/health,/owners/+/:id/catsis/owners/:id/cats). For a class without@Controllerit throwsError("<ClassName> is not a controller").@Log(entries)wraps the method: every call adds"<method>(<arguments as JSON, joined by ', '>)"toentries, then runs the original with the samethisand arguments and returns its result.
When it fails
- "the tests could not run: not written yet": the decorators run as soon as the spec defines its classes, so a decorator that throws stops everything before a single test.
- A route appears under the wrong controller, or under none: it was recorded against
target, the prototype, whileroutesOflooks up the class. Usetarget.constructor. - "keeps
this" fails withCannot read properties of undefined: the wrapper is an arrow function, or calls the original withoutapply(this, …). /cats/or//health: empty segments were joined. Trim the slashes and drop the empty parts.
Remember
- A decorator is a function run once, when the class is defined, with what it decorates.
- A factory takes arguments and returns the decorator.
- A method decorator gets the prototype; the class is
target.constructor. - Wrap with a
functionandoriginal.apply(this, args), orthisis lost.
Stuck? Show a hint
A decorator factory is a function that returns the decorator: Controller(prefix) returns (target) => { … }. A method decorator receives the class's prototype, the method's name and its descriptor; target.constructor is the class. WeakMap<Function, …> keyed by that class keeps the prefix and a list of routes. To wrap, keep descriptor.value, then replace it with a function(this: unknown, ...args) that records the call and returns original.apply(this, args).
Press Run tests to start the app. Its log appears here.Tests
- routing.spec.tsrun to see its tests