NestJS – Custom Decorators

August 3, 20268 min readUpdated 9/5/2026

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: string

This 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 true

No 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.