Decorator Kustom NestJS: Metadata, Reflector & Guard

Foto oleh Robosprout via Openverse (CC BY 2.0)
createParamDecorator membangun parameter decorator yang factory function-nya berjalan pada setiap request yang cocok untuk mengambil dan mengembalikan nilai bagi argumen handler, seperti mengambil user dari objek request. SetMetadata sama sekali tidak berjalan per request — ia menempelkan sepasang key/value ke metadata class atau method sekali saja, saat Nest memindai controller Anda ketika aplikasi mulai. Sesuatu yang lain, biasanya guard atau interceptor yang memakai Reflector, harus secara eksplisit membaca metadata itu kembali agar berdampak.
Hampir selalu berarti guard memanggil reflector.get atau getAllAndOverride dengan key metadata yang salah, atau memeriksa context.getClass() padahal seharusnya context.getHandler() (atau sebaliknya) sesuai tempat decorator sebenarnya diterapkan. Bisa juga terjadi karena guard sudah didaftarkan tetapi tidak pernah benar-benar dipanggil karena hilang dari UseGuards pada route tersebut, atau guard lain sebelumnya di rantai sudah mengembalikan true dan menghentikan pemeriksaan lebih awal.
get() membaca metadata dari satu target saja, jadi hanya memeriksa method atau hanya class, bukan keduanya. getAllAndOverride() memeriksa daftar target secara berurutan sesuai prioritas dan mengembalikan nilai pertama yang bukan undefined, yang persis perilaku yang diinginkan untuk kasus seperti role: Roles di level method seharusnya menimpa default di level controller, dan getAllAndOverride merangkum urutan prioritas itu dalam satu pemanggilan tanpa logika fallback manual.
Bisa — factory function pada createParamDecorator menerima ExecutionContext lengkap, jadi setelah memanggil context.switchToHttp().getRequest() Anda mendapat objek request yang sama seperti yang tersedia untuk decorator Nest lainnya, termasuk params, query, dan headers. Membangun beberapa decorator kecil dengan cara ini cukup umum (decorator bergaya Roles berbeda — ia memakai SetMetadata bukan createParamDecorator — tetapi decorator berbasis request seperti CurrentUser, ClientIp, atau RequestId semuanya mengikuti pola yang sama).
ExecutionContext yang mendasarinya bersifat agnostik terhadap transport, tetapi switchToHttp() secara spesifik hanya masuk akal untuk request HTTP. Untuk resolver GraphQL Anda akan memakai GqlExecutionContext.create(context) untuk mendapatkan konteks resolver, dan untuk microservices Anda memakai switchToRpc(). Deklarasi decorator itu sendiri (createParamDecorator) tidak berubah — hanya method switchTo mana yang dipanggil di dalam factory function yang berubah sesuai transport.

Foto oleh Robosprout via Openverse (CC BY 2.0)
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.
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.
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.
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 }
}
}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.
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 }
}
}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.
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.
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.