Custom decorators and Reflector in NestJS: why @Public() on a controller has no effect Intermediate
Estimated reading time: 9 min read
Read first
The problem
Section titled “The problem”An order API has a globally registered authentication guard. GET /catalog must be public, GET /orders/101 requires the support role, and POST /orders/101/refund is admin only. Handlers read req.user directly in dozens of places.
The team adds @Public() to CatalogController, but requests without a token still get 401. With @Roles(['support']) on the class and @Roles(['admin']) on refund(), it is unclear whether user sam (role support) may issue refunds.
This article answers: where does a decorator store its information, how does the guard read it back, and does the handler override or merge with the class?
What to choose? (summary first)
Section titled “What to choose? (summary first)”| Situation | Choose | Condition |
|---|---|---|
On/off flag (@Public()), method beats class |
getAllAndOverride(key, [handler, class]) |
Read both handler and class |
| Permission list, method more specific than class | getAllAndOverride |
The method fully replaces the class requirement |
| Cumulative list (tags, extra scopes) | getAllAndMerge |
Check for an empty array, don’t use it as a boolean |
| A new metadata decorator | Reflector.createDecorator<T>() |
Nest ≥ 10.2; auto-generated key, no collisions |
| Getting the user in a handler | createParamDecorator → @CurrentUser() |
A guard has already attached req.user |
| Bundling decorators that always go together | applyDecorators(...) |
No conditional logic inside |
Core concepts
Section titled “Core concepts”With experimentalDecorators (the configuration NestJS uses), a decorator is just a function. TypeScript calls it once, when the class is defined, i.e. when the file is loaded, not on every request. The demo prints this line before any app is created:
[class definition] decorator runs for OrdersController.get[class definition] done, no app has been created yetSo a decorator knows nothing about the request or the user. Its job is to write metadata: SetMetadata(key, value) calls Reflect.defineMetadata (from the reflect-metadata library). On a method, the metadata attaches to the handler function (descriptor.value); on a class, it attaches to the constructor.
| Component | Runs at | Job |
|---|---|---|
SetMetadata / Reflector.createDecorator |
Class definition | Write key → value onto the handler or class |
Reflector (a provider from @nestjs/core) |
Every request, inside guards/interceptors | Read metadata by key |
createParamDecorator |
Definition: registers the parameter; request: runs the factory | Produces a handler parameter value |
applyDecorators |
Class definition | Calls several decorators in turn |
The common ways to read with Reflector. The examples below are real results with refund() carrying [admin] and the class carrying [support]:
| Call | Result | Rule |
|---|---|---|
get(Roles, handler) |
['admin'] |
A single target only |
get(Roles, class) |
['support'] |
A single target only |
getAllAndOverride(Roles, [handler, class]) |
['admin'] |
The first non-undefined value in array order |
getAllAndMerge(Roles, [handler, class]) |
['admin', 'support'] |
Arrays are concatenated, objects shallow-merged ({...a, ...b}) |
The [handler, class] order is a convention, not an implicit default. Swap it to [class, handler] and the class wins.
How it works
Section titled “How it works”The diagram answers: when is the metadata written, and when sam calls refund(), which value does the guard base its decision on?
defineMetadataThe controller file loads: the decorators run, writing roles [support] on the class and [admin] on refund().
| Step | Metadata | Guard sees | Result |
|---|---|---|---|
| 1. File loaded | class [support], refund [admin] |
No guard yet | |
| 2–3. Bootstrap, request arrives | Unchanged | req.user = sam (roles [support]) |
|
| 4–5. Override | Unchanged | [admin] (stops at the handler) |
403 |
| 6–7. Merge | Unchanged | [admin, support] |
200 |
Metadata does not change per request. To decide based on the user, the guard combines metadata with req.user; it never writes back into the metadata.
Decorator code from the demo:
export const IS_PUBLIC_KEY = 'isPublic';export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);export const Roles = Reflector.createDecorator<string[]>();export const AdminOnly = () => applyDecorators(Roles(['admin']), HttpCode(200));
export const CurrentUser = createParamDecorator((data: keyof User | undefined, ctx: ExecutionContext) => { const req = ctx.switchToHttp().getRequest<{ user?: User }>(); return data ? req.user?.[data] : req.user;});The guard reads it back:
// AuthGuard (APP_GUARD)const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ctx.getHandler(), ctx.getClass()]);if (isPublic) return true;
// RolesGuard: pass the decorator itself, the return type is string[]const required = this.reflector.getAllAndOverride(Roles, [ctx.getHandler(), ctx.getClass()]);Reflector.createDecorator<string[]>() (added in v10.2.0, PR #12237) returns a decorator with a KEY property. If you pass no key, Nest generates a random 21-character string, so two modules cannot collide on a key.
Pass the decorator into reflector.get(Roles, ...) and TypeScript infers string[], with no manual generic needed.
Operational details
Section titled “Operational details”ExecutionContext.switchToHttp().getRequest()returns the adapter’s request (Express by default). For GraphQL or microservices the context differs;getHandler()andgetClass()still work.- Pipes apply to custom parameters just like
@Body(). ButValidationPipeignores them by default; you needvalidateCustomDecorators: true. createParamDecoratortakes pipes afterdata:@CurrentUser('id', ParseIntPipe), or the pipe in first position if there is nodata.createDecoratorcalled with no argument stores{}(code:value ?? {}), which is truthy. Boolean flags should pass an explicit value or useSetMetadata(key, true).getAllAndMergewith a single primitive value returns an array ([true]); with no values it returns[].- A global guard registered via
APP_GUARDgetsReflectorinjected by DI. Registered viaapp.useGlobalGuards(new AuthGuard(...)), you must passReflectoryourself (see DI container).
@CurrentUser() runs at the pipe step, after every guard. The diagram shows why guards cannot use this decorator:
Loading diagram…
Real-world example
Section titled “Real-world example”The orders-demo.ts demo (NestJS 11.2.6) builds a real app, calls listen(0), and sends requests with fetch. The token is the user name (demo only):
== AuthGuard reads handler + class, RolesGuard uses getAllAndOverrideGET /catalog - -> 200 {"items":["sku-1","sku-2"]}GET /orders/health - -> 200 {"ok":true}GET /orders/101 - -> 401 {"message":"Unauthorized","statusCode":401}GET /orders/101 alice -> 403 {"message":"requires one of [support]",...}GET /orders/101 sam -> 200 {"id":"101","viewedBy":"sam"}POST /orders/101/refund sam -> 403 {"message":"requires one of [admin]",...}POST /orders/101/refund root -> 200 {"id":"101","refundedBy":"root"}GET /orders/me sam -> 200 {"id":2}GET /orders/me ghost -> 400 {"message":["id must be an integer number"],...}
== RolesGuard uses getAllAndMergePOST /orders/101/refund sam -> 200 {"id":"101","refundedBy":"sam"}
== Bug: AuthGuard reads only the handlerGET /catalog - -> 401 {"message":"Unauthorized","statusCode":401}GET /orders/health - -> 200 {"ok":true}Three takeaways:
@Public()on the class (/catalog) and on a method (/orders/health) both work only if the guard reads[handler, class].- Same metadata, override gives
sama 403, merge gives a 200. The permission semantics live in how the guard reads, not in the decorator. @CurrentUser(new ValidationPipe({ validateCustomDecorators: true }))blocks a broken claim (id: 'x') with a 400.
npx --prefix examples ts-node examples/src/nestjs-custom-decorators/orders-demo.tsTrade-offs
Section titled “Trade-offs”Pros
-
Access rules sit next to the route; reading the code shows which routes are public and which roles they need.
-
One global guard handles every route; handlers don’t repeat
if (!req.user). -
createDecoratorgives strict types and collision-free keys. -
@CurrentUser()decouples handlers from the Expressreq, making them easy to test.
< slot="cons"> >
-
Hidden rules: a missing decorator blocks (or exposes) a route with no compile error.
-
Override/merge semantics are decided by the guard; changing the guard changes permissions on every route.
-
Metadata is static and cannot express data-based permissions (orders belonging to this user).
-
Stacked decorators are hard to debug when you don’t know the read order.
Cons
Data-based permissions (a user may only view their own orders) still have to be checked in the service; see Secure coding.
Production checklist
Section titled “Production checklist”- The global guard reads metadata with
[ctx.getHandler(), ctx.getClass()], with tests for@Public()on both class and method. - Choose override or merge by business meaning, and document it in the guard’s comment.
- Use
Reflector.createDecorator<T>()for new decorators; for string keys, add a module prefix (orders:roles). - Deny by default: routes without metadata still go through authentication.
- Enable
validateCustomDecoratorsif you wantValidationPipeto check values from@CurrentUser(). - Bundle repeated decorator sets (
Roles+HttpCode+ Swagger) withapplyDecorators. - Have an e2e test listing public routes, to catch routes exposed when a new decorator is added.
Common mistakes
Section titled “Common mistakes”The most common mistake is a global guard that reads only the handler, exactly like the “Bug” part of the demo:
@Public() on a controller is ignored
Common
const isPublic = this.reflector.get<boolean>(IS_PUBLIC_KEY, ctx.getHandler());Prefer
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ctx.getHandler(), ctx.getClass()]);Why
@Public() on a class writes metadata to the constructor, not to each method. Reading only the handler leaves /catalog at 401.
The second mistake is more dangerous because it opens routes instead of blocking them:
| Misconception | Consequence | What to do |
|---|---|---|
| Decorators run on every request | Per-user logic placed in a decorator | Decorators only write metadata; logic lives in the guard |
Only my module uses the 'roles' key |
Another library overwrites it and the guard reads the wrong value | createDecorator or a key prefix |
Using @CurrentUser() in a guard |
Not available: the param factory runs after guards | The guard reads req.user |
| Merge is safer than override | Merge widens permissions (sam can refund) |
Choose by meaning, with tests |
Self-check
Section titled “Self-check”- With targets swapped to
[ctx.getClass(), ctx.getHandler()], what status doessamget calling refund under override? - Why does
@Public()on a class have no effect when the guard usesget(..., ctx.getHandler())? - Why can
@Roles()decorators fromcreateDecoratorin two different modules never overwrite each other’s metadata? - A guard needs the user id. Where does it read it from, and why not from
@CurrentUser()?
Summary
Section titled “Summary”References
Section titled “References”- Custom decorators — NestJS:
createParamDecorator, pipes with custom parameters,applyDecorators. - Execution context — NestJS:
Reflector,createDecorator, override and merge examples. - Guards — NestJS: a RolesGuard reading metadata.
- Authentication — NestJS: the
@Public()pattern with a global guard. - NestJS v10.2.0 release: the version that added Reflector’s typed decorator factory.
- TypeScript — Decorators: when decorators are invoked.