Note: This post is a translated version of an article originally published on my personal blog. You can read the original Korean post here.
What Are Decorators?
Decorators are a metaprogramming syntax that lets you attach metadata — or extend behavior — on classes, methods, properties, and parameters without modifying the original code.
They're commonly used for:
- Logging — track when methods are called
- Validation — enforce constraints on class properties
- Dependency Injection — wire up services automatically
- Authorization — guard methods behind permission checks
Frameworks like Angular and NestJS lean heavily on decorators for all of the above.
Setting Up
First, enable decorators in your tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true
}
}
How Decorators Work
A decorator is just a function. When you apply it to a class or method, TypeScript calls that function at definition time with some useful arguments.
// simple-decorator.ts
export function SimpleDecorator(
target: any, // class prototype or constructor
propertyKey?: string, // method or property name
descriptor?: PropertyDescriptor | number, // property descriptor or param index
) {
console.log('====================================================');
console.log('Target', target);
console.log('Property Key', propertyKey);
console.log('Descriptor', descriptor);
}
Let's apply it everywhere to see what gets passed in:
// example.ts
import { SimpleDecorator } from './simple-decorator';
@SimpleDecorator
class Example {
@SimpleDecorator
private _myProperty01: string = '';
@SimpleDecorator
get property() {
return this._myProperty01;
}
@SimpleDecorator
foo(
@SimpleDecorator
name: string,
@SimpleDecorator
age: number,
) {
return `Name: ${name}, Age: ${age}`;
}
}
For a method decorator, the output looks like:
// Target {} ← Example.prototype
// Property Key foo ← method name
// Descriptor {
// value: [Function: foo],
// writable: true,
// enumerable: false,
// configurable: true
// }
Key takeaway: descriptor.value is the actual method function. Swap it out inside the decorator, and you've changed how the method behaves — without touching the original class.
Quick Summary
| Argument | What it is |
|---|---|
target |
Class prototype (for methods/properties) or constructor (for class decorators) |
propertyKey |
The name of the decorated method or property |
descriptor |
PropertyDescriptor for methods, parameter index for parameter decorators |
Building a Logger Decorator
Now let's build something real. This Logger decorator wraps a method and logs before/after each call.
export interface LoggerOptions {
mode: 'simple' | 'detailed';
}
export function Logger({ mode }: LoggerOptions = { mode: 'simple' }) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value; // save original method
// replace with a wrapper function
descriptor.value = function (...args: any[]) {
console.log(`Calling ${propertyKey}`);
if (mode === 'detailed') {
console.log('Arguments:', args);
}
const result = originalMethod.apply(this, args); // call original
console.log(`${propertyKey} finished`);
if (mode === 'detailed') {
console.log('Return value:', result);
}
return result;
};
return descriptor;
};
}
A few things happening here:
-
Loggeris a decorator factory — it accepts options and returns the actual decorator function. This pattern lets you parameterize your decorators. - We stash
descriptor.value(the original method) before replacing it. - The new function logs, calls the original, logs again, and returns the result.
In action:
import { Logger } from './logger';
class Example2 {
@Logger({ mode: 'detailed' })
foo(name: string, age: number) {
return `Name: ${name}, Age: ${age}`;
}
}
const example2 = new Example2();
const returnValue = example2.foo('Ina', 20);
// Calling foo
// Arguments: [ 'Ina', 20 ]
// foo finished
// Return value: Name: Ina, Age: 20
The method works exactly as before — we just wrapped it.
Building a Validator Decorator
Now for something a bit more complex. We'll use reflect-metadata to store validation rules on properties, then run them with a validate() function.
Install the package first:
npm install reflect-metadata
Step 1 — Define the MinLength property decorator
import 'reflect-metadata';
export interface MinLength {
length: number;
message?: string;
}
export function MinLength({ length, message = 'Minimum length is ${length}.' }: MinLength) {
return function (target: any, propertyKey: string) {
// store validation metadata on the class prototype
const constraint = { value: length, message };
Reflect.defineMetadata('minLength', constraint, target, propertyKey);
};
}
Reflect.defineMetadata attaches arbitrary metadata to a class property. We key it with 'minLength' so we can look it up later.
Step 2 — Write the validate function
import 'reflect-metadata';
export function validate(target: any) {
for (const propertyKey of Object.keys(target)) {
// retrieve stored metadata for this property
const constraint = Reflect.getMetadata('minLength', target, propertyKey);
if (constraint) {
const value = target[propertyKey];
// run the actual check
if (typeof value === 'string' && value.length < constraint.value) {
const errorMessage = constraint.message.replace('${length}', constraint.value.toString());
console.log(errorMessage);
throw new Error(errorMessage);
}
}
}
console.log('Validation passed.');
}
Step 3 — Apply and test
import { MinLength, validate } from './validator';
class Example {
@MinLength({ length: 3, message: 'Name must be at least ${length} characters.' })
value: string;
constructor(value: string) {
this.value = value;
}
}
const example1 = new Example('Ina');
validate(example1); // ✅ Validation passed.
const example2 = new Example('MO');
validate(example2); // ❌ Name must be at least 3 characters.
No need to write validation logic inside the class itself — the decorator handles it declaratively.
Wrapping Up
We covered:
- What decorators are and the arguments they receive
- The decorator factory pattern for passing options
- Wrapping methods with a
Loggerto add pre/post logging - Storing validation metadata with
reflect-metadataand running it withvalidate()
Once you understand this pattern, frameworks like NestJS become a lot less magical. Their @Injectable(), @Body(), @IsString() and friends are all doing the same thing under the hood.
You can find all the code from this post in the decorator playground repo. Clone it, run pnpm install, then pnpm test to see everything in action.
Top comments (0)