Two kinds of custom decorator are worth writing, and they solve different problems. One pulls a value out of the request so your handler can declare it as a parameter. The other attaches data to a route so something else in the pipeline can read it back.
Both are small. Both remove a category of repetition that otherwise accumulates in every controller.
Parameter decorators
Every handler in this application that needs to know who is calling declares it:
@Get('me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: AuthenticatedUser) {
return user
}@CurrentUser() is not built in. It is nine lines:
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): AuthenticatedUser => {
const request = context.switchToHttp().getRequest<Request & { user?: AuthenticatedUser }>()
return request.user as AuthenticatedUser
},
)createParamDecorator takes a function and returns a decorator. The function
receives whatever was passed at the call site as data, plus the
ExecutionContext, and whatever it returns becomes the parameter's value.
Compare the alternative, written out in every handler that needs the caller:
// The wrong way, repeated in a dozen controllers.
create(@Req() req: Request & { user?: AuthenticatedUser }, @Body() dto: CreateProjectDto) {
const user = req.user as AuthenticatedUser
return this.projectsService.create(user, dto)
}Three costs, and the third is the one that matters. The handler now takes a request object, so
calling it from a test means constructing one. The cast is repeated everywhere, so the day the
shape changes there are a dozen places to fix. And the handler's signature no longer says what it
depends on — @CurrentUser() user: AuthenticatedUser is a statement about the method's
inputs; @Req() req is a statement that it might use anything.
Using the data argument
The first parameter is whatever the decorator was called with, which lets one decorator serve several uses:
export const CurrentUserField = createParamDecorator(
(field: keyof AuthenticatedUser | undefined, context: ExecutionContext) => {
const request = context.switchToHttp().getRequest<Request & { user?: AuthenticatedUser }>()
const user = request.user as AuthenticatedUser
return field ? user[field] : user
},
)
// @CurrentUserField() user: AuthenticatedUser
// @CurrentUserField('id') userId: stringThis is exactly how Nest's own @Body() works: @Body() gives the whole
body, @Body('email') gives one property.
Whether it is an improvement depends on the codebase. This application deliberately does not do
it — every handler takes the whole AuthenticatedUser and passes it to a service,
because the services want more than one field and a handler that destructures the caller into
three parameters is harder to read, not easier.
The rule that comes with it
@CurrentUser() reads what JwtAuthGuard put on the request. On a route
with no guard, request.user is undefined, and the cast means nothing
complains until the first property access.
That failure is a TypeError — a 500, not an authentication bypass, because there is
no user to impersonate. But it is a confusing 500, so the rule is simple and worth stating in the
code: this decorator and @UseGuards(JwtAuthGuard) always appear together.
You could make the decorator throw when the user is missing, which turns a confusing 500 into a clear one. What you cannot do is make it safe — a decorator cannot enforce that a guard ran, and pretending otherwise would be worse than the honest cast.
Metadata decorators
The second kind attaches data to a route rather than extracting it:
export const ROLES_KEY = 'contractor:roles'
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles)One line of behaviour. SetMetadata attaches a value to the handler under a key, and
that is all — it enforces nothing. Applied to a route it looks like this:
@Post()
@Roles(UserRole.HOMEOWNER)
create(@CurrentUser() user: AuthenticatedUser, @Body() dto: CreateProjectDto) {
return this.projectsService.create(user, dto)
}Nothing has happened yet. The metadata sits on the method waiting for something to read it.
Reading it back with Reflector
The other half is a guard:
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<UserRole[] | undefined>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
])
if (!required || required.length === 0) return true
const request = context.switchToHttp().getRequest<Request & { user?: AuthenticatedUser }>()
const user = request.user
if (!user || !required.includes(user.role)) {
throw new ForbiddenException('Your account cannot do that.')
}
return true
}
}Reflector is a provider Nest supplies for reading metadata.
getAllAndOverride looks in the targets in order and returns the first value it finds,
so a method-level @Roles wins over a class-level one. Plain get would only
ever look at one of the two, which is exactly the bug that makes an override silently not
override.
Its sibling getAllAndMerge combines them instead — the right choice when the
metadata is additive, like a list of required permissions rather than a replacement set.
The line that decides how the whole scheme behaves is the third one:
if (!required || required.length === 0) return trueNo decorator means no role requirement, so the route is open to any signed-in user. Returning
false there would 403 every unannotated endpoint — including
PATCH /projects/:id/status, which deliberately has no @Roles because its
rules depend on the row. That would present as "status transitions are broken" and send you
looking in entirely the wrong place.
Why this indirection is worth it
The alternative is a role check written inline in each handler. It works, and it goes wrong in a specific way: a check written twelve times is a check that eventually disagrees with itself. One handler compares against a string literal that has drifted, another was added without a check at all, and none of it is visible from outside the method body.
With the decorator, every route's requirement is declared where the route is declared, one guard enforces all of them, and adding a role means changing an enum and one decorator — not auditing a dozen conditionals.
It also makes the requirement testable in isolation, because the metadata and the enforcement are separate objects. That is what lets the guard's own tests set up a handler carrying arbitrary metadata without any of the application present.
Composing decorators
When several decorators always travel together, applyDecorators makes one out of
them:
export function ContractorOnly() {
return applyDecorators(
UseGuards(JwtAuthGuard, RolesGuard),
Roles(UserRole.CONTRACTOR),
)
}
// @ContractorOnly()
// @Post()
// create(...) {}The gain is that the guard order — which is load-bearing, as lesson 8 covers — is written once and cannot be got wrong at a call site.
The cost is a layer of indirection: a reader now has to open ContractorOnly to find
out what protects the route. This application does not use it, because with two guards and one
decorator the explicit version is short enough to read at a glance. It earns its place when the
combination is four or five decorators, or when it also carries OpenAPI annotations.
Decorators are evaluated once
The thing that catches people is when the code inside a decorator runs.
A decorator's arguments are evaluated at class definition time — once, at
startup, as the module is loaded. The function you pass to
createParamDecorator runs per request; everything outside it does not.
// The wrong way. `Date.now()` is evaluated once when the class is defined, so every
// request gets the timestamp of application startup.
export const RequestTime = createParamDecorator(
(_data: unknown, _context: ExecutionContext) => startedAt,
)
const startedAt = Date.now()The same reasoning explains why @Roles(UserRole.CONTRACTOR) cannot consult the
database: it is a value computed at startup and attached to a method. Anything that varies per
request has to be read inside the guard, or inside the parameter decorator's callback.
Testing them
A parameter decorator's factory is a plain function, but createParamDecorator wraps
it and does not hand it back. In practice you test the two kinds differently.
For a metadata decorator, assert the metadata is attached — using the same
Reflector that will read it:
it('reads the metadata from the handler and the class, so a method-level @Roles can win', () => {
const spy: Array<unknown[]> = []
const original = reflector.getAllAndOverride.bind(reflector)
reflector.getAllAndOverride = ((key: string, targets: unknown[]) => {
spy.push([key, targets])
return original(key, targets as never)
}) as typeof reflector.getAllAndOverride
guard.canActivate(contextFor([UserRole.HOMEOWNER], homeowner))
expect(spy).toHaveLength(1)
expect(spy[0][0]).toBe(ROLES_KEY)
expect(spy[0][1]).toHaveLength(2)
})That the guard asks for two targets is the assertion doing real work: it is what makes a method-level decorator override a class-level one, and dropping to a single target would be a silent behaviour change.
For a parameter decorator, the practical route is an end-to-end test of a route that uses it, since what you actually care about is that it and its guard cooperate.
Naming the metadata key
export const ROLES_KEY = 'contractor:roles'Namespaced, and exported rather than repeated as a literal. Metadata keys share one global space
per target, so a bare 'roles' can collide with a library using the same word — and the
collision is silent, because whoever writes last wins and both sides look correct.
Exporting the constant means the decorator and the guard cannot drift: a typo in one of two string literals is a runtime failure that presents as "the decorator does nothing", while a typo in an imported identifier does not compile.
Decorators the ecosystem gives you
Once you recognise the metadata pattern you start seeing it everywhere, and that is the practical
payoff of this lesson. @CacheKey and @CacheTTL attach values a caching
interceptor reads. @Throttle attaches limits a rate-limiting guard reads.
@ApiProperty attaches documentation a Swagger module reads at startup.
All of them are SetMetadata under a namespaced key, plus something in the pipeline
holding a Reflector. None of them do anything on their own — which is why a
@Throttle with no ThrottlerGuard registered silently has no effect, and
why that is a confusing bug until you know the shape.
The distinction to keep
createParamDecorator is for reading something out of the request into a parameter.
SetMetadata plus Reflector is for describing a route so that a guard,
interceptor or filter can act on it.
The second one is worth recognising in the wild, because it is the mechanism behind most of the declarative behaviour in Nest and in libraries built on it — including caching TTLs, rate limits, API documentation and role checks. Once you have written one, all of them read the same way.
Next: interceptors — the only stage that sees a request on the way in and the response on the way out.