Mastering Custom Decorators and Metadata in NestJS

Mastering Custom Decorators and Metadata in NestJS

Ralph Nguyen

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.