NestJS is a powerful, opinionated framework for building scalable Node.js applications. One of its most advanced and flexible features is custom decorators powered by TypeScript metadata reflection.
In this guide, you'll learn:
- What custom decorators are
- How metadata works in NestJS
- How to create and use your own decorators
- Real-world use cases like role-based access, logging, and feature flags
Let’s dive in.
What Are Decorators in TypeScript/NestJS?
A decorator is a special kind of declaration attached to classes, methods, or properties using the @
symbol.
NestJS heavily relies on decorators like:
@Controller()
@Get()
@Injectable()
@Param()
,@Body()
Under the hood, many of these use metadata to describe behaviors. You can do the same.
How Metadata Works
NestJS uses the ReflectMetadata
API (backed by TypeScript's reflect-metadata
library) to store and retrieve metadata.
To set metadata:
SetMetadata('key', value)
To read metadata later (usually inside a guard or interceptor):
this.reflector.get('key', context.getHandler())
Example 1: Custom @Roles()
Decorator
Step 1: Create the Decorator
// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
Step 2: Create a Guard
// roles.guard.ts
import {
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.get<string[]>('roles', context.getHandler());
if (!roles) return true;
const request = context.switchToHttp().getRequest();
const user = request.user;
return roles.some(role => user?.roles?.includes(role));
}
}
Step 3: Use in a Controller
@Controller('users')
@UseGuards(RolesGuard)
export class UsersController {
@Get('admin')
@Roles('admin')
getAdminData() {
return 'Only for admins';
}
@Get('common')
@Roles('admin', 'editor')
getSharedData() {
return 'Admins and Editors can see this';
}
}
Example 2: @LogAction()
Decorator
You may want to tag certain actions for logging or auditing.
Decorator:
// log-action.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const LOG_ACTION_KEY = 'log_action';
export const LogAction = (action: string) =>
SetMetadata(LOG_ACTION_KEY, action);
Interceptor:
// logging.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
constructor(private reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler) {
const action = this.reflector.get<string>(
'log_action',
context.getHandler(),
);
const req = context.switchToHttp().getRequest();
const user = req.user;
return next.handle().pipe(
tap(() => {
if (action) {
console.log(`[LOG] User ${user?.id} performed: ${action}`);
}
}),
);
}
}
Controller Usage:
@UseInterceptors(LoggingInterceptor)
@Controller('posts')
export class PostsController {
@Post()
@LogAction('CREATE_POST')
createPost() {
return { message: 'Post created' };
}
}
Example 3: @Public()
Route Decorator
By default, all routes are protected. You can mark a route as public using a custom decorator.
Decorator:
// public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
Guard:
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.get<boolean>(
IS_PUBLIC_KEY,
context.getHandler(),
);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
return !!request.user; // Basic check; plug in your logic
}
}
Usage:
@Controller('auth')
export class AuthController {
@Public()
@Post('login')
login() {
return { token: 'abc123' };
}
@Get('me')
getProfile() {
return { name: 'John Doe' };
}
}
Combine Decorators and Guards/Interceptors
You can mix multiple decorators and retrieve them all using the Reflector
service.
For example:
@Roles('admin')
@LogAction('DELETE_USER')
@Throttle(5, 60)
@Delete(':id')
deleteUser() {}
A RolesGuard
, LoggingInterceptor
, and ThrottlerGuard
can all operate on the same handler and read the relevant metadata.
Best Practices
- Use
SetMetadata
only for static values (roles, flags, etc). - Use
@Inject(Reflector)
to read metadata inside guards/interceptors. - Combine with NestJS features like Pipes, Guards, and Interceptors for powerful abstractions.
- Avoid putting business logic inside decorators. Keep them declarative.
Summary
Custom decorators + metadata in NestJS give you a declarative and clean way to:
- Add role-based access control
- Enable/disable public routes
- Attach logging and metrics
- Mark routes for caching, throttling, etc.