I avoided decorators for years. Thought they were over-engineered nonsense. Then I used NestJS and... okay, fine. They're useful.
It took me a while to admit that. I'd look at Angular codebases covered in @ symbols and think "this is just abstraction for abstraction's sake." And maybe sometimes it is. But the underlying idea -- attaching behavior to a class or method at definition time without modifying its source -- that's genuinely handy once you stop fighting it.
What Are Decorators?
A decorator is a function that gets called with information about the thing it's attached to. You write @something above a class, method, property, or parameter, and TypeScript calls that function at runtime. The function can observe, modify, or replace the thing it decorates.
Decorators have been a stage 3 TC39 proposal for ages. TypeScript has supported an experimental version for years, and since TypeScript 5.0, the newer TC39 spec is natively supported too. In practice, most production code still uses the experimental version because that's what NestJS, Angular, and TypeORM expect. So that's what we'll focus on here.
To turn them on, add this to your tsconfig.json:
{
"compilerOptions": {
"target": "ES2016",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
The emitDecoratorMetadata flag is optional but worth enabling. It tells TypeScript to emit type information that the reflect-metadata library can read at runtime. This is how NestJS and Angular do dependency injection -- they inspect the types of constructor parameters and figure out what to inject. Sounds like magic, but it's just metadata.
Class Decorators
A class decorator sits above a class declaration and receives the constructor function as its argument. You can seal it, extend it, or replace it entirely.
Here's a simple one that seals a class so nobody can bolt extra properties onto it:
function Sealed(constructor: Function) {
Object.seal(constructor);
Object.seal(constructor.prototype);
}
@Sealed
class UserService {
name: string = "UserService";
getUsers() {
return ["Alice", "Bob"];
}
}
Okay, sealing a class is a bit niche. Where class decorators get interesting is when they extend the original constructor -- adding properties or behavior that the class itself doesn't define.
function WithTimestamp(constructor: T) {
return class extends constructor {
createdAt = new Date();
updatedAt = new Date();
};
}
@WithTimestamp
class Order {
id: number;
total: number;
constructor(id: number, total: number) {
this.id = id;
this.total = total;
}
}
const order = new Order(1, 99.99);
console.log((order as any).createdAt); // Logs the current date
ORM libraries use this pattern all the time to slap createdAt and updatedAt onto entity classes without anyone having to type those fields out manually. It's clean. I'll give it that.
Method Decorators
This is where I started warming up to decorators. Method decorators get three arguments: the target object (the prototype or constructor), the method name, and the property descriptor. They can wrap or replace the method entirely.
Classic example -- logging:
function Log(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`Calling ${propertyKey} with args:`, args);
const result = originalMethod.apply(this, args);
console.log(`${propertyKey} returned:`, result);
return result;
};
return descriptor;
}
class Calculator {
@Log
add(a: number, b: number): number {
return a + b;
}
}
const calc = new Calculator();
calc.add(2, 3);
// Logs: Calling add with args: [2, 3]
// Logs: add returned: 5
You can do this with a wrapper function, sure. But slapping @Log above a method is just less noise than manually wrapping every function you want to trace. And once you accept that, the door opens to caching, auth checks, rate limiting -- all as single-line annotations.
Here's memoization as a decorator:
function Memoize(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
const cache = new Map();
descriptor.value = function (...args: any[]) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(`Cache hit for ${propertyKey}`);
return cache.get(key);
}
const result = originalMethod.apply(this, args);
cache.set(key, result);
return result;
};
return descriptor;
}
class DataService {
@Memoize
fetchExpensiveData(id: number): object {
// Simulate expensive computation
return { id, data: Math.random() };
}
}
Call it twice with the same argument, second call hits the cache. No extra wiring needed. I can see why people like this.
Property Decorators and Parameter Decorators
Property decorators are a bit more limited -- they don't get a property descriptor, just the target and the key. So you mostly use them to stash metadata that other code reads later.
import "reflect-metadata";
const REQUIRED_KEY = Symbol("required");
function Required(target: any, propertyKey: string) {
const existingRequired: string[] =
Reflect.getMetadata(REQUIRED_KEY, target) || [];
existingRequired.push(propertyKey);
Reflect.defineMetadata(REQUIRED_KEY, existingRequired, target);
}
class CreateUserDto {
@Required
username!: string;
@Required
email!: string;
bio?: string;
}
function validate(obj: any): boolean {
const requiredFields: string[] =
Reflect.getMetadata(REQUIRED_KEY, obj) || [];
for (const field of requiredFields) {
if (obj[field] === undefined || obj[field] === null) {
throw new Error(`Missing required field: ${field}`);
}
}
return true;
}
Parameter decorators work similarly -- they record which parameter index maps to which injected service. They're the backbone of how NestJS figures out what to pass into your constructors.
const INJECT_KEY = Symbol("inject");
function Inject(serviceKey: string) {
return function (target: any, propertyKey: string | undefined, paramIndex: number) {
const existingInjections: { index: number; key: string }[] =
Reflect.getMetadata(INJECT_KEY, target) || [];
existingInjections.push({ index: paramIndex, key: serviceKey });
Reflect.defineMetadata(INJECT_KEY, existingInjections, target);
};
}
class UserController {
constructor(
@Inject("UserService") private userService: any,
@Inject("LoggerService") private logger: any
) {}
}
When NestJS boots up, it reads that metadata, looks up "UserService" and "LoggerService" in its container, and hands them to the constructor. You never call new UserController() yourself. The framework does it for you, with the right dependencies. That's the magic trick, and parameter decorators are how it works.
Decorator Factories
Most decorators you'll write in practice are factories -- functions that return decorator functions. This lets you pass configuration to your decorators.
function Throttle(delayMs: number) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
let lastCall = 0;
descriptor.value = function (...args: any[]) {
const now = Date.now();
if (now - lastCall < delayMs) {
console.log(`${propertyKey} throttled`);
return;
}
lastCall = now;
return originalMethod.apply(this, args);
};
return descriptor;
};
}
class SearchService {
@Throttle(1000)
search(query: string) {
console.log(`Searching for: ${query}`);
// Perform search logic
}
}
@Throttle(1000) -- that reads well. You know what it does without looking at the implementation. That's the argument for decorators right there: intent is visible at the call site.
One gotcha with stacking decorators: factories evaluate top-to-bottom, but the actual decorator functions execute bottom-to-top.
function First() {
console.log("First factory");
return function (target: any, key: string, desc: PropertyDescriptor) {
console.log("First decorator");
};
}
function Second() {
console.log("Second factory");
return function (target: any, key: string, desc: PropertyDescriptor) {
console.log("Second decorator");
};
}
class Example {
@First()
@Second()
method() {}
}
// Output:
// First factory
// Second factory
// Second decorator
// First decorator
This ordering trips people up. If your decorators depend on each other or modify the same descriptor, the order matters. Test your stacks.
Metadata Reflection
The reflect-metadata library is a polyfill for an API that lets decorators read and write metadata on classes, methods, and properties. When emitDecoratorMetadata is on, TypeScript automatically emits type information you can query at runtime.
import "reflect-metadata";
function PrintType(target: any, propertyKey: string) {
const designType = Reflect.getMetadata("design:type", target, propertyKey);
console.log(`${propertyKey} type: ${designType.name}`);
}
function PrintParamTypes(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const paramTypes = Reflect.getMetadata("design:paramtypes", target, propertyKey);
const typeNames = paramTypes.map((t: any) => t.name).join(", ");
console.log(`${propertyKey} param types: ${typeNames}`);
}
class ProductService {
@PrintType
name!: string;
@PrintParamTypes
findProduct(id: number, includeReviews: boolean): object {
return {};
}
}
// Output:
// name type: String
// findProduct param types: Number, Boolean
TypeScript emits three kinds of metadata: design:type for properties, design:paramtypes for method parameters, and design:returntype for return values. This is how NestJS auto-generates Swagger docs from your type annotations and how TypeORM maps class properties to database columns. The types you write in TypeScript become runtime information that frameworks can act on. Pretty slick, honestly.
Real-World Example: Express Route Decorators
Alright, let's build something real. This is a simplified version of what NestJS does under the hood -- declarative routing for Express using decorators.
import "reflect-metadata";
import express, { Request, Response, Router } from "express";
const ROUTES_KEY = Symbol("routes");
interface RouteDefinition {
path: string;
method: "get" | "post" | "put" | "delete" | "patch";
handlerName: string;
}
function Controller(basePath: string) {
return function (constructor: Function) {
Reflect.defineMetadata("basePath", basePath, constructor);
};
}
function createMethodDecorator(method: RouteDefinition["method"]) {
return function (path: string) {
return function (target: any, propertyKey: string) {
const routes: RouteDefinition[] =
Reflect.getMetadata(ROUTES_KEY, target.constructor) || [];
routes.push({ path, method, handlerName: propertyKey });
Reflect.defineMetadata(ROUTES_KEY, routes, target.constructor);
};
};
}
const Get = createMethodDecorator("get");
const Post = createMethodDecorator("post");
const Put = createMethodDecorator("put");
const Delete = createMethodDecorator("delete");
@Controller("/api/users")
class UserController {
@Get("/")
getAllUsers(req: Request, res: Response) {
res.json([{ id: 1, name: "Alice" }]);
}
@Get("/:id")
getUserById(req: Request, res: Response) {
res.json({ id: req.params.id, name: "Alice" });
}
@Post("/")
createUser(req: Request, res: Response) {
res.status(201).json({ id: 2, ...req.body });
}
@Delete("/:id")
deleteUser(req: Request, res: Response) {
res.status(204).send();
}
}
function registerController(app: express.Application, ControllerClass: any) {
const instance = new ControllerClass();
const basePath: string = Reflect.getMetadata("basePath", ControllerClass);
const routes: RouteDefinition[] =
Reflect.getMetadata(ROUTES_KEY, ControllerClass) || [];
const router = Router();
routes.forEach((route) => {
router[route.method](route.path, instance[route.handlerName].bind(instance));
console.log(`Registered ${route.method.toUpperCase()} ${basePath}${route.path}`);
});
app.use(basePath, router);
}
const app = express();
registerController(app, UserController);
app.listen(3000);
Look at that UserController class. No manual app.get() calls. No route registration boilerplate. You declare what each method handles, and the framework wires it up. The first time I saw this pattern click in a real project, I understood why people build entire frameworks around decorators.
Real-World Example: Validation Decorators
One more practical one. Validation decorators keep your rules right next to the data they validate. Libraries like class-validator are built on this exact pattern.
import "reflect-metadata";
const VALIDATORS_KEY = Symbol("validators");
interface ValidatorRule {
propertyKey: string;
validator: (value: any) => boolean;
message: string;
}
function createValidator(validatorFn: (value: any) => boolean, message: string) {
return function (target: any, propertyKey: string) {
const validators: ValidatorRule[] =
Reflect.getMetadata(VALIDATORS_KEY, target.constructor) || [];
validators.push({ propertyKey, validator: validatorFn, message });
Reflect.defineMetadata(VALIDATORS_KEY, validators, target.constructor);
};
}
const IsString = createValidator(
(v) => typeof v === "string",
"must be a string"
);
const IsEmail = createValidator(
(v) => typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
"must be a valid email"
);
const MinLength = (min: number) =>
createValidator(
(v) => typeof v === "string" && v.length >= min,
`must be at least ${min} characters`
);
const IsPositive = createValidator(
(v) => typeof v === "number" && v > 0,
"must be a positive number"
);
class CreateProductDto {
@IsString
@MinLength(3)
name!: string;
@IsString
description!: string;
@IsPositive
price!: number;
@IsEmail
contactEmail!: string;
}
function validateObject(obj: T): { valid: boolean; errors: string[] } {
const validators: ValidatorRule[] =
Reflect.getMetadata(VALIDATORS_KEY, obj.constructor) || [];
const errors: string[] = [];
for (const rule of validators) {
const value = (obj as any)[rule.propertyKey];
if (!rule.validator(value)) {
errors.push(`${rule.propertyKey} ${rule.message}`);
}
}
return { valid: errors.length === 0, errors };
}
const product = new CreateProductDto();
product.name = "AB";
product.price = -5;
product.contactEmail = "not-an-email";
const result = validateObject(product);
console.log(result);
// { valid: false, errors: [
// "name must be at least 3 characters",
// "description must be a string",
// "price must be a positive number",
// "contactEmail must be a valid email"
// ]}
The validation rules live right on the DTO. You can read the class and immediately know what's expected. No hunting through a separate validation file. No manually calling if (!name || name.length < 3) scattered across your route handlers.
I still don't use decorators everywhere. But when the shoe fits.
They work best when you have cross-cutting concerns -- logging, caching, validation, route mapping, access control -- that would otherwise be duplicated across a bunch of methods. They're not a good fit for one-off behavior. And they add a layer of indirection that can make debugging harder if you overdo it. But used with restraint, they clean up real code in ways that actually matter.
Comments (0)
No comments yet. Be the first to share your thoughts!