Skip to main content

Як створити та використовувати власні декоратори в 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 напряму

Швидкий приклад

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

typescript
// 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 зчитують пізніше:

typescript
// 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:

typescript
// 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-декоратор об'єднує кілька декораторів в один виклик:

typescript
// 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:

typescript
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-декораторах:

typescript
// Неправильно: падає у 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, коли користувач відсутній:

typescript
// Неправильно: типи кажуть 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:

typescript
// Неправильно: перекриває @Body() з @nestjs/common, ламає validation pipes export const Body = createParamDecorator(...); // Правильно: унікальний префікс export const ParsedBody = createParamDecorator(...);

Використовувати SetMetadata напряму замість іменованої константи:

typescript
// Неправильно: легко зробити опечатку, складно рефакторити @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-декоратор

typescript
// 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

typescript
// 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

typescript
// 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-клієнта. Цей патерн робить таку помилку неможливою.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?