Як створити та використовувати власні декоратори в NestJS?
Власні декоратори в NestJS (custom decorators) - це TypeScript-функції, що або витягують значення з контексту запиту, або зберігають метадані для guards і pipes через Reflect.metadata.
Теорія
TL;DR
- Декоратор схожий на ярлик на деталі конвеєра: прикріплюєш його до методу або параметра, і NestJS зчитує його під час виконання та автоматично змінює поведінку
- Два основних типи: param-декоратори (витягують дані із запиту) і metadata-декоратори (зберігають дані для guards та pipes)
createParamDecoratorдля витягування із запиту,SetMetadataдля метаданих,applyDecoratorsщоб об'єднати обидва підходи- Правило: якщо копіюєш
req.userабоSetMetadata('roles', ...)у 2+ місцях - час створити декоратор. Для одноразової логіки - використовуй guards або pipes напряму
Швидкий приклад
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user; // заповнюється JwtAuthGuard
return data ? user?.[data] ?? null : user ?? null;
},
);
// Використання в контролері
@Get('me')
getMe(@CurrentUser() user: User) {
return user; // повний об'єкт користувача
}
@Get('email')
getEmail(@CurrentUser('email') email: string) {
return { email }; // тільки email
}data - це аргумент всередині виклику декоратора. @CurrentUser('email') передає рядок 'email' як data. Без аргументу повертається весь об'єкт. Повертати null замість undefined варто, щоб типи TypeScript у контролері не вводили в оману.
Головна різниця: param vs metadata декоратори
Param-декоратори запускаються для кожного запиту всередині createParamDecorator і витягують значення з ExecutionContext. Metadata-декоратори запускаються один раз під час визначення класу через SetMetadata і зберігають пари ключ-значення, які guards зчитують пізніше за допомогою Reflector. Якщо плутати ці підходи, метадані виявляються undefined у рантаймі - і guard непомітно блокує або пропускає все підряд.
Коли використовувати
- Повторне витягування
request.userу 2+ контролерах: param-декоратор - Перевірка ролей та прав доступу на кількох маршрутах: metadata-декоратор разом із guard
- Власна валідація поля: property-декоратор з
registerDecorator - Чотири декоратори на кожному захищеному endpoint: composed-декоратор через
applyDecorators - Одноразова логіка: використовуй guards або pipes напряму, без декоратора
Типи декораторів та їх API
Param-декоратор запускається для кожного запиту і отримує ExecutionContext:
// current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] ?? null : user ?? null;
},
);Metadata-декоратор зберігає статичні дані, які guards зчитують пізніше:
// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);Guard зчитує це через Reflector.getAllAndOverride:
// roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(), // спочатку рівень методу
context.getClass(), // потім рівень класу
]);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.roles?.includes(role));
}
}Composed-декоратор об'єднує кілька декораторів в один виклик:
// auth.decorator.ts
import { applyDecorators, UseGuards, SetMetadata } from '@nestjs/common';
import { ApiBearerAuth, ApiUnauthorizedResponse } from '@nestjs/swagger';
export function Auth(...roles: string[]) {
return applyDecorators(
SetMetadata(ROLES_KEY, roles),
UseGuards(JwtAuthGuard, RolesGuard),
ApiBearerAuth(),
ApiUnauthorizedResponse({ description: 'Unauthorized' }),
);
}Замість чотирьох декораторів на кожному захищеному маршруті - один @Auth('admin').
Property-декоратор для власних правил валідації через class-validator:
import { registerDecorator, ValidationOptions } from 'class-validator';
export function IsStrongPassword(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isStrongPassword',
target: object.constructor,
propertyName,
options: validationOptions,
validator: {
validate(value: string) {
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).{8,}$/.test(value);
},
defaultMessage() {
return 'Пароль має містити великі та малі літери, цифри та спеціальні символи';
},
},
});
};
}Як це працює всередині
TypeScript компілює декоратори як статичні виклики функцій, що запускаються коли клас визначається, а не коли приходить запит. Вони зберігають пари ключ-значення через Reflect.defineMetadata у WeakMap, прив'язаному до класу або методу. NestJS сканує ці дані під час запуску застосунку і підключає метадані до системи DI. Коли приходить запит, guards та pipes викликають Reflect.getMetadata, щоб прочитати збережене.
Reflector.getAllAndOverride в guards перевіряє метадані на рівні методу першими, потім на рівні класу. Метод перемагає, якщо визначені обидва. Саме тому можна встановити @Roles('admin') на контролер і перевизначити конкретний маршрут через @Roles('user') - ближчий рівень завжди виграє.
Типові помилки
Припускати HTTP-контекст у всіх param-декораторах:
// Неправильно: падає у WebSocket або мікросервісах
export const UserId = createParamDecorator(
(_, ctx) => ctx.switchToHttp().getRequest().user.id
);
// Правильно: спочатку перевір тип контексту
export const UserId = createParamDecorator(
(_, ctx) => {
if (ctx.getType() === 'http') {
return ctx.switchToHttp().getRequest().user?.id;
}
return ctx.switchToRpc().getData().user?.id;
}
);Повертати undefined, коли користувач відсутній:
// Неправильно: типи кажуть User, рантайм дає undefined -> помилки в продакшені
export const CurrentUser = createParamDecorator(
(_, ctx) => ctx.switchToHttp().getRequest().user
);
// Правильно: явно повертай null, щоб контролер знав що обробляти
export const CurrentUser = createParamDecorator(
(_, ctx) => ctx.switchToHttp().getRequest().user ?? null
);Відсутній поліфіл reflect-metadata:
NestJS автоматично імпортує reflect-metadata у main.ts. Але якщо ти переносиш декоратор у спільну бібліотеку без цього імпорту, Reflector.getMetadata скрізь повертає undefined і guards перестають працювати. Перевіряй, що import 'reflect-metadata' виконується до будь-якого коду з декораторами.
Конфлікт назв із вбудованими декораторами NestJS:
// Неправильно: перекриває @Body() з @nestjs/common, ламає validation pipes
export const Body = createParamDecorator(...);
// Правильно: унікальний префікс
export const ParsedBody = createParamDecorator(...);Використовувати SetMetadata напряму замість іменованої константи:
// Неправильно: легко зробити опечатку, складно рефакторити
@SetMetadata('roles', ['admin'])
// Правильно: виноси ключ у константу, використовуй і в декораторі, і в guard
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);Де зустрічається в реальних проектах
@nestjs/passportвикористовує@User()param-декоратор для витягування JWT-користувача- NestJS GraphQL:
@CurrentUser()у resolver-ах зGqlExecutionContextзамість HTTP-контексту - E-commerce бекенди (замовлення, інвентар):
@Roles()зRolesGuardдля контролю доступу - API-платформи:
@Auth()composed-декоратор, що прикріплює@UseGuards,@ApiBearerAuth()та@SetMetadataодним рядком і тримає Swagger-документацію актуальною
Питання для співбесіди
Q: Яка різниця між createParamDecorator і методним декоратором?
A: createParamDecorator запускається для кожного запиту і витягує значення з ExecutionContext. Методний декоратор запускається один раз під час визначення класу - зазвичай щоб встановити статичні метадані через SetMetadata.
Q: Чи може фабрична функція всередині createParamDecorator бути async?
A: Так. Функція може використовувати await, наприклад для запиту до бази даних. Вона запускається після завершення guards, тому request.user вже заповнений, якщо auth guard виконався.
Q: Як написати unit-тест для власного param-декоратора?
A: Створи mock ExecutionContext із потрібними даними запиту, потім виклич фабричну функцію декоратора напряму. Повний застосунок NestJS не потрібен.
Q: Чому Reflector.getAllAndOverride перевіряє і handler, і клас?
A: Він обходить ланцюжок метаданих і бере перше визначене значення. Це дозволяє встановити @Roles('admin') на контролер і перевизначити конкретні маршрути - ближчий рівень завжди перемагає.
Q (senior): Якщо @Roles('admin') стоїть на класі контролера, а @Roles('user') на методі, що поверне getAllAndOverride?
A: Поверне ['user']. getAllAndOverride починає з handler (методу) і бере перше визначене значення, тому метадані методу завжди перемагають метадані класу.
Приклади
Базовий: CurrentUser param-декоратор
// current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user; // встановлюється JwtAuthGuard до запуску декоратора
return data ? user?.[data] ?? null : user ?? null;
},
);
// profile.controller.ts
@Controller('profile')
@UseGuards(JwtAuthGuard)
export class ProfileController {
@Get()
getMe(@CurrentUser() user: User) {
return user;
}
@Get('email')
getEmail(@CurrentUser('email') email: string) {
return { email };
}
}Аргумент всередині виклику декоратора потрапляє в data. Без аргументу отримуєш весь об'єкт користувача. Повернення null замість undefined робить TypeScript-типи у контролері чесними.
Середній: Декоратор Roles з RolesGuard
// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
// roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.roles?.includes(role));
}
}
// orders.controller.ts
@Controller('orders')
@UseGuards(JwtAuthGuard, RolesGuard)
export class OrdersController {
@Post()
@Roles('customer', 'admin') // 403 якщо user.roles не містить жодної з ролей
create(@Body() dto: CreateOrderDto) {
return this.ordersService.create(dto);
}
@Delete(':id')
@Roles('admin') // перевизначає метадані класу, якщо вони там є
remove(@Param('id') id: string) {
return this.ordersService.remove(id);
}
}getAllAndOverride спочатку перевіряє getHandler() (метод), потім getClass() (контролер). Якщо визначено лише одне місце, береться воно.
Просунутий: Composed-декоратор Auth
// auth.decorator.ts
import { applyDecorators, UseGuards, SetMetadata } from '@nestjs/common';
import { ApiBearerAuth, ApiUnauthorizedResponse } from '@nestjs/swagger';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard';
export const ROLES_KEY = 'roles';
export function Auth(...roles: string[]) {
return applyDecorators(
SetMetadata(ROLES_KEY, roles),
UseGuards(JwtAuthGuard, RolesGuard),
ApiBearerAuth(),
ApiUnauthorizedResponse({ description: 'Unauthorized' }),
);
}
// users.controller.ts
@Controller('users')
export class UsersController {
@Get()
@Auth('admin')
findAll() {
return this.usersService.findAll();
}
@Get('me')
@Auth('customer', 'admin')
getProfile(@CurrentUser() user: User) {
return user;
}
}Без @Auth кожен маршрут потребує чотирьох окремих декораторів. З ним контролер залишається читабельним, а Swagger-документація оновлюється автоматично на кожному новому маршруті. Я бачив проекти де @ApiBearerAuth() забували додати на новий endpoint, і це ламало генерацію OpenAPI-клієнта. Цей патерн робить таку помилку неможливою.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.