Setiap codebase NestJS yang sudah cukup besar biasanya punya kosakata decorator sendiri — CurrentUser, Roles, Public, ApiVersion. Decorator-decorator ini terlihat seperti fitur bawaan framework, padahal hampir selalu hanya pembungkus tipis yang dibuat tim dalam waktu singkat. Bagian yang sering membingungkan bukan sintaks decorator itu sendiri, melainkan hubungan dua langkah antara menempelkan metadata saat compile time dan membacanya kembali saat request time. Tulisan ini membangun dua decorator kustom paling umum dari nol — sebuah parameter decorator yang mengambil user terautentikasi dari request, dan sebuah method decorator yang menandai route dengan role yang dibutuhkan — lalu merangkai guard yang membaca tanda itu lewat Reflector untuk menegakkannya.
Model mental: decorator menempel, guard membaca
Decorator NestJS hampir tidak pernah menjalankan logika bisnis itu sendiri. createParamDecorator membungkus sebuah fungsi yang berjalan sekali per request untuk mengambil sebuah nilai dan menyerahkannya sebagai argumen ke handler. SetMetadata melakukan hal berbeda dan, bagi pemula, lebih membingungkan: ia sama sekali tidak menjalankan apa pun saat request berlangsung. Ia menempelkan sepasang key dan value ke metadata class atau method menggunakan Reflect.defineMetadata di baliknya, pada saat modul dimuat. Tidak ada yang membaca metadata itu kecuali ada sesuatu yang secara aktif mencarinya. Sesuatu itu hampir selalu berupa guard, interceptor, atau pipe yang memegang instance Reflector, pembungkus bawaan NestJS untuk Reflect Metadata API. Pisahkan dua fakta ini di kepala Anda — decorator menulis metadata, Reflector membaca metadata — dan pola ini berhenti terasa seperti sihir.
Kalau bingung apakah sebuah decorator berjalan per-request atau hanya sekali saat startup, tanyakan ia membungkus apa. createParamDecorator dan interceptor berjalan per-request. SetMetadata berjalan sekali, saat Nest memindai controller Anda ketika aplikasi mulai — ia hanya meninggalkan catatan untuk dibaca sesuatu yang lain nanti.
Membangun parameter decorator CurrentUser
createParamDecorator menerima sebuah factory function dengan dua argumen: payload data sembarang yang Anda berikan saat menerapkan decorator, dan ExecutionContext, abstraksi NestJS atas transport apa pun yang memicu request tersebut — HTTP, RPC, atau WebSocket. Di dalam konteks HTTP, Anda memanggil context.switchToHttp().getRequest() untuk mendapatkan objek request Express atau Fastify yang mendasarinya, yang pada titik ini sudah memiliki properti user yang ditempelkan oleh guard autentikasi sebelumnya (biasanya strategi Passport). Tugas decorator hanyalah menjangkau request tersebut dan mengembalikan tepat bagian yang diminta handler.
Kode decorator-nya
Factory di bawah menerima key opsional. Panggil sebagai CurrentUser() untuk mendapatkan seluruh objek user; panggil sebagai CurrentUser dengan argumen email untuk hanya mendapatkan properti itu, dengan tipe yang benar karena key dibatasi ke keyof AuthedUser. Ini membatasi apa yang bisa secara tidak sengaja di-destructure handler dan membuat signature controller lebih menjelaskan dirinya sendiri.
// current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from "@nestjs/common"
export interface AuthedUser {
id: string
email: string
roles: string[]
}
export const CurrentUser = createParamDecorator(
(data: keyof AuthedUser | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest()
const user: AuthedUser | undefined = request.user
// @CurrentUser() returns the whole user
// @CurrentUser("email") returns just that property
return data ? user?.[data] : user
},
)Diterapkan pada controller, decorator ini merangkum apa yang seharusnya berupa pembacaan request.user yang tersebar di setiap handler menjadi satu argumen yang bertipe dan dapat digunakan ulang.
// profile.controller.ts
import { Controller, Get, UseGuards } from "@nestjs/common"
import { AuthGuard } from "@nestjs/passport"
import { CurrentUser, AuthedUser } from "./current-user.decorator"
@Controller("profile")
export class ProfileController {
@Get()
@UseGuards(AuthGuard("jwt"))
getProfile(@CurrentUser() user: AuthedUser) {
return { id: user.id, email: user.email }
}
@Get("email")
@UseGuards(AuthGuard("jwt"))
getEmail(@CurrentUser("email") email: string) {
return { email }
}
}Kenapa ini lebih baik daripada membaca request.user langsung
Menjangkau parameter Req dan mengambil request.user secara manual memang berhasil, tetapi mengikat setiap handler pada bentuk request Express dan pada di mana pun guard autentikasi Anda menyimpan user. Memusatkan pencarian itu dalam satu decorator berarti jika suatu saat Anda mengganti strategi Passport, mengganti nama properti, atau pindah ke library autentikasi lain, hanya ada satu tempat yang perlu diperbarui. Ini juga cocok dengan pengujian otomatis — factory function di balik parameter decorator hanyalah fungsi biasa yang bisa diuji secara terpisah, tanpa perlu menjalankan seluruh request HTTP.
Membangun method decorator Roles dengan SetMetadata
SetMetadata menerima sebuah key dan sebuah value lalu mengembalikan decorator yang menempelkan pasangan itu ke class atau method mana pun ia diterapkan. Memanggil SetMetadata langsung di setiap route sebenarnya bisa berhasil tetapi kurang enak dibaca dan rawan salah ketik pada string key, sehingga pola idiomatis NestJS membungkusnya dalam factory function kecil — biasanya dinamai sesuai apa yang diwakilinya, seperti Roles — yang meneruskan daftar rest-parameter berupa role sebagai nilai metadata. Mengekspor key sebagai konstanta sendiri, ROLES_KEY, menghindari perlunya mengetik ulang string mentah roles setiap kali guard nanti perlu mencarinya kembali.
// roles.decorator.ts
import { SetMetadata } from "@nestjs/common"
export const ROLES_KEY = "roles"
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles)Diterapkan di atas sebuah handler, Roles dengan argumen admin dan finance-manager terbaca seperti aturan akses berbahasa manusia biasa, tetapi pada titik ini sifatnya murni deskriptif — belum ada yang menegakkannya. Penegakan itu adalah tugas guard.
// invoices.controller.ts
import { Controller, Delete, Param, UseGuards } from "@nestjs/common"
import { AuthGuard } from "@nestjs/passport"
import { Roles } from "./roles.decorator"
import { RolesGuard } from "./roles.guard"
@Controller("invoices")
export class InvoicesController {
@Delete(":id")
@Roles("admin", "finance-manager")
@UseGuards(AuthGuard("jwt"), RolesGuard)
remove(@Param("id") id: string) {
return { removed: id }
}
}Membaca kembali metadata dengan Reflector.getAllAndOverride
Sebuah guard mengimplementasikan CanActivate, dan Nest memanggil method canActivate-nya untuk setiap request masuk yang cocok dengan route yang dilindungi guard tersebut. Suntikkan Reflector lewat constructor — ia adalah provider bawaan, jadi tidak perlu didaftarkan di modul mana pun — lalu panggil getAllAndOverride dengan key metadata dan sebuah array target yang diperiksa, diurutkan dari paling spesifik ke paling umum: handler dulu, baru class controller. Ini membuat Roles di level controller bertindak sebagai default untuk semua route di controller itu, sementara Roles di level method pada satu handler tertentu bisa menimpanya. Jika tidak ada route dalam rantai pemanggilan yang pernah menerapkan Roles, getAllAndOverride mengembalikan undefined, dan guard harus memperlakukannya sebagai 'tidak ada pembatasan' serta meloloskan request, bukan menolaknya diam-diam.
// roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"
import { Reflector } from "@nestjs/core"
import { ROLES_KEY } from "./roles.decorator"
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// getAllAndOverride checks the handler (method) first, then falls back
// to the class — so a controller-level @Roles() acts as a default that
// a method-level @Roles() can override.
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
])
// No @Roles() on this route at all — nothing to enforce, let it through.
if (!requiredRoles || requiredRoles.length === 0) {
return true
}
const { user } = context.switchToHttp().getRequest()
return requiredRoles.some((role) => user?.roles?.includes(role))
}
}Guard yang memperlakukan ketiadaan decorator Roles sebagai penolakan otomatis akan mengunci setiap route yang belum diberi anotasi begitu guard didaftarkan secara global. Selalu periksa dulu kasus undefined/kosong dan kembalikan true — tugas decorator adalah mengikutsertakan route ke dalam pembatasan, bukan tugas guard untuk mengasumsikan pembatasan itu ada.
Menyatukan semuanya: role berbasis enum untuk keamanan tipe
Versi string polos di atas berfungsi tetapi mentolerir salah ketik seperti admni yang tidak bisa ditangkap TypeScript. Peningkatan kecil membuat decorator Roles dan guard didukung oleh enum atau union type bersama, bukan string biasa, sehingga compiler menolak role yang tidak dikenal di titik pemanggilan alih-alih gagal diam-diam saat runtime. Tidak ada yang berubah pada pemanggilan Reflector — getAllAndOverride bersifat generik, jadi mengganti parameter tipe dari array string ke array Role adalah seluruh migrasinya.
Terapkan guard secara global dengan APP_GUARD di root module setelah pola Roles Anda stabil, alih-alih mengulang UseGuards dengan RolesGuard di setiap controller. Route yang tidak butuh pembatasan cukup tidak pernah menerapkan Roles, dan pemeriksaan undefined pada guard otomatis meloloskannya.
Ke mana pola ini bisa diperluas
Pola tempel-lalu-baca yang sama menggerakkan sebagian besar komponen bawaan NestJS yang terasa seperti sihir framework: decorator Public yang banyak tim tambahkan untuk melewati guard autentikasi global, decorator cache-control yang dibaca sebuah interceptor, atau decorator versi API yang dibaca route resolver kustom. Setelah model dua langkah ini dipahami — decorator untuk menulis penanda, panggilan Reflector di dalam guard atau interceptor untuk membacanya kembali — membangun decorator baru sebagian besar tinggal memilih nama key dan memutuskan apakah ia cocok dipasang di parameter, method, atau class.
Reflector juga menyediakan get() biasa untuk membaca metadata dari satu target dan getAll() untuk membaca dari beberapa target tanpa perilaku override. Gunakan getAllAndOverride khususnya saat Anda ingin metadata level method mengalahkan metadata level class, yang merupakan kasus umum untuk guard route.
Sumber
- Dokumentasi resmi NestJS — Custom decorators (docs.nestjs.com/custom-decorators)
- Dokumentasi resmi NestJS — Guards (docs.nestjs.com/guards)
- Dokumentasi resmi NestJS — Execution context dan Reflector (docs.nestjs.com/fundamentals/execution-context)
- Dokumentasi resmi NestJS — Authorization dengan Roles dan RolesGuard (docs.nestjs.com/security/authorization)

