TypeScript Decorators for Metaprogramming
Decorators are one of TypeScript’s most powerful features for metaprogramming. They let you add metadata and modify the behavior of classes, methods, accessors, and properties without changing their actual code. If you’ve used annotations in Java or attributes in C#, you’ll find decorators familiar—though TypeScript’s implementation is more flexible.
TypeScript decorators are functions the compiler calls at runtime, letting you add validation, logging, and metadata without touching the original code. You’ll work with class, method, accessor, property, and parameter decorators across real examples.
Enabling decorators in TypeScript
Before you can use decorators, you need to enable them in your TypeScript configuration. Add the experimentalDecorators option to your tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
The experimentalDecorators option enables the decorator syntax. The emitDecoratorMetadata option is optional—it emits type metadata for decorators that use reflection, which is useful when combined with libraries like TypeORM or routing frameworks.
Understanding decorator functions
At their core, decorators are functions that get called by TypeScript at runtime. When you write @decoratorName, TypeScript calls your function with information about what you’re decorating.
Here’s a simple class decorator:
function logged<T extends Function>(constructor: T) {
console.log(`Creating instance of: ${constructor.name}`);
return constructor;
}
@logged
class User {
constructor(public name: string) {}
}
const user = new User("Alice");
// Output: Creating instance of: User
The decorator function receives the class constructor as its argument. You can use this to observe, modify, or replace the class definition entirely.
Decorator factories for customization
Plain decorators like the one above are useful, but often you need to configure how a decorator behaves. That’s where decorator factories come in—a factory is a function that returns a decorator function.
function sealed(constructor: Function) {
Object.seal(constructor);
Object.seal(constructor.prototype);
}
function printable(value: string) {
return function(target: Function) {
target.prototype.printFormat = value;
};
}
@sealed
@printable("PDF")
class Document {
constructor(public title: string) {}
}
console.log((new Document("Report") as any).printFormat);
// Output: PDF
Decorator factories let you pass arguments to customize behavior. The factory returns the actual decorator function that gets applied to the class.
Method Decorators
Method decorators let you wrap or modify methods. They receive three arguments: the target (class prototype for instance methods, constructor for static methods), the method name, and the property descriptor.
function readonly(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
descriptor.writable = false;
}
class Calculator {
@readonly
static VERSION = "1.0.0";
@readonly
add(a: number, b: number): number {
return a + b;
}
}
const calc = new Calculator();
// This would fail because the method is read-only:
// calc.add = function() { return 0; };
This decorator makes a method immutable after definition. You can also use method decorators to add logging, timing, or validation logic around method calls.
Accessor Decorators
Accessors (getters and setters) can also be decorated. TypeScript applies decorators to the first accessor in document order, since the property descriptor combines both get and set.
function validated(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalSetter = descriptor.set;
descriptor.set = function(value: number) {
if (value < 0) {
throw new Error(`${propertyKey} cannot be negative`);
}
originalSetter?.call(this, value);
};
}
class Account {
private _balance: number = 0;
@validated
set balance(value: number) {
this._balance = value;
}
get balance(): number {
return this._balance;
}
}
const account = new Account();
account.balance = 100; // Works
account.balance = -50; // Throws: balance cannot be negative
Accessor decorators are powerful for adding validation or transforming values before they’re stored.
Property Decorators
Property decorators receive two arguments: the target and the property name. Unlike method and accessor decorators, property decorators don’t receive a property descriptor because there’s no standard way to describe instance properties in ES5.
function formatDate(target: any, propertyKey: string) {
const symbol = Symbol(propertyKey);
Object.defineProperty(target, propertyKey, {
get() {
return new Date(this[symbol]).toLocaleDateString();
},
set(value: string) {
this[symbol] = value;
}
});
}
class Event {
@formatDate
date: string = "2026-03-07";
}
const event = new Event();
console.log(event.date); // Output: 3/7/2026 (depending on locale)
Property decorators work with Object.defineProperty to intercept getter and setter behavior for class properties. Unlike method or accessor decorators, property decorators do not receive a descriptor, so you must use Object.defineProperty directly to control access.
Parameter decorators
Parameter decorators let you add metadata to function parameters. They are commonly used for dependency injection or validation frameworks. Unlike property decorators, parameter decorators cannot change the parameter value itself; they only attach metadata that a framework or companion method decorator reads later.
function required(target: Object, propertyKey: string | symbol, parameterIndex: number) {
const parameterKeys = Symbol("requiredParameters");
if (!target[parameterKeys]) {
target[parameterKeys] = [];
}
target[parameterKeys].push(parameterIndex);
}
class UserService {
createUser(@required name: string, @required email: string) {
console.log(`Creating user: ${name} (${email})`);
}
}
const service = new UserService();
service.createUser("Alice", "alice@example.com");
// Output: Creating user: Alice (alice@example.com)
Parameter decorators don’t prevent the function from being called—you’d need to combine them with method decorators to actually validate parameters before invocation.
Decorator composition and evaluation order
When multiple decorators apply to a single declaration, they evaluate in a specific order. Factory expressions are evaluated top-to-bottom, but the decorators themselves execute bottom-to-top.
function first() {
console.log("first(): factory evaluated");
return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
console.log("first(): called");
};
}
function second() {
console.log("second(): factory evaluated");
return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
console.log("second(): called");
};
}
class Example {
@first()
@second()
method() {}
}
// Output:
// first(): factory evaluated
// second(): factory evaluated
// second(): called
// first(): called
Understanding this order matters when building composed decorators where execution sequence affects behavior.
Practical use cases
Decorators shine in real-world scenarios. Popular frameworks use them extensively. Angular relies on decorators for components, services, and dependency injection. TypeORM uses them to define database entities. NestJS builds its entire architecture around decorator-based patterns.
Common practical applications include:
- Validation: Check that values meet certain criteria before assignment or method execution
- Logging: Track method calls, arguments, and return values
- Timing: Measure how long operations take
- Caching: Store and retrieve computed results
- Dependency Injection: Automatically inject dependencies into class constructors
Common Mistakes
Forgetting to return what you need. Method, accessor, and class decorators can return values to replace the original. If you forget to return the descriptor (for methods/accessors) or constructor (for classes), you’ll lose the original behavior.
Decorating both getter and setter. TypeScript doesn’t allow decorating both accessors for the same property. Apply decorators to the first accessor in document order, since the descriptor handles both get and set.
Assuming types change. Decorators that add properties at runtime don’t affect TypeScript’s type system. The compiler doesn’t know about runtime additions:
function addMetadata(target: Function) {
target.metadata = { created: new Date() };
}
@addMetadata
class Thing {}
const thing = new Thing();
// TypeScript error: Property 'metadata' does not exist
(thing as any).metadata; // Works at runtime
Conclusion
Decorators give you powerful metaprogramming capabilities in TypeScript. They let you cleanly separate cross-cutting concerns like logging, validation, and caching from your business logic. Frameworks like Angular and NestJS demonstrate how TypeScript decorators can create elegant, declarative APIs.
Start small by adding a simple logging decorator to a method, then experiment with accessor decorators for validation. As you get comfortable, you’ll find decorators are one of TypeScript’s most versatile features.
Decorator order matters
The order of decorators is easy to miss because the syntax looks compact. When several decorators apply to the same class member, they do not all run at the same moment, and the order can change the final result. Keep that in mind when one decorator depends on another having already run. A short comment beside the decorator stack can save a lot of debugging later. The more behavior a decorator hides, the more important that ordering becomes.
Keep metadata small
Decorators are often used to attach metadata for frameworks, but metadata can become noisy if every field stores a new flag. Only record what the rest of the program actually needs. Small metadata objects are easier to inspect and less likely to drift out of sync with the code they describe. If the information starts to look like business logic, it may belong in a normal function or a config object instead of in a decorator.
Watch Compatibility
Decorator support has changed across TypeScript versions and JavaScript proposals, so it is worth checking the toolchain before you rely on the syntax. A feature that works in one compiler setup may need a flag or a transform in another. Keep the intended runtime and compiler versions written down in the project setup so upgrades do not surprise the team. That makes future migrations calmer, especially when a library depends on the newer decorator model.
Summary
| Decorator Type | Arguments | Use Case |
|---|---|---|
| Class | constructor | Modify or seal class, add metadata |
| Method | target, propertyKey, descriptor | Add logging, timing, make read-only |
| Accessor | target, propertyKey, descriptor | Validate values, transform on set |
| Property | target, propertyKey | Modify property behavior |
| Parameter | target, propertyKey, index | Mark required params, dependency injection |