
TypeScript Decorators: Mastering Metaprogramming and Clean Code in TS 5.x
Metaprogramming allows developers to write code that inspects, wraps, or modifies other code at runtime. In TypeScript, decorators are the primary tool for metaprogramming.
For years, TypeScript supported an early, experimental version of decorators. However, in TypeScript 5.0, the compiler introduced support for the official TC39 standard decorators. This new specification brings stable, standardized syntax that runs natively in modern JavaScript runtimes without relying on experimental compiler flags.
In this guide, we will explore how standard decorators work in TypeScript 5.x, understand the difference between legacy and standard decorators, and write custom decorators for logging and input validation.
Legacy vs. Standard Decorators
If you have used decorators in Angular or NestJS, you are likely familiar with the legacy implementation. Upgrading to TypeScript 5.x standard decorators brings several key changes:
- No Compiler Flag Required: You no longer need to set "experimentalDecorators": true in your tsconfig.json. Standard decorators work out of the box.
- New Parameter Signature: The parameters passed to decorator functions are standardized. Instead of receiving target prototypes and property descriptors, decorators now receive the target value (e.g., the method or class) and a context object.
- Strict Types: Standard decorators are fully type-safe. The compiler verifies that the decorator returns a value compatible with the item it is decorating.
The Decorator Context Object
Every standard decorator receives a context object as its second argument. This object contains metadata about the decorated item:
kind: Indicates whether it is decorating a "class", "method", "getter", "setter", "field", or "accessor".name: The name of the decorated property or class.private: A boolean indicating if the member is private.static: A boolean indicating if the member is static.addInitializer: A function to register a callback that runs when the class is initialized.
Writing a Method Decorator (Logging Example)
Method decorators can intercept, modify, or replace method behaviors. Let us write a custom @log decorator that prints the arguments and execution time of a class method.
// decorators.ts
export function log<T, A extends any[], R>(
target: (...args: A) => R,
context: ClassMethodDecoratorContext
) {
const methodName = String(context.name);
// Return a replacement function for the method
return function (this: T, ...args: A): R {
console.log(`[LOG] Calling method "${methodName}" with args:`, args);
const start = performance.now();
// Call the original method
const result = target.apply(this, args);
const duration = performance.now() - start;
console.log(`[LOG] Method "${methodName}" returned. Elapsed: ${duration.toFixed(2)}ms`);
return result;
};
}To apply this decorator, import it and prepend it to a class method:
// app.ts
import { log } from './decorators';
class PaymentProcessor {
@log
processPayment(amount: number, currency: string) {
// Simulate database and network delay
for (let i = 0; i < 1e6; i++) {}
return { success: true, transactionId: 'TX-100293' };
}
}
const processor = new PaymentProcessor();
processor.processPayment(250, 'USD');When processPayment is called, the wrapper runs, logs the parameters, calls the actual function, logs the execution time, and returns the transaction object seamlessly.
Writing a Class Decorator (Freeze Example)
Class decorators can wrap or modify class constructors. A simple use case is freezing the class prototype to prevent runtime modifications.
// decorators.ts
export function freeze(
value: Function,
context: ClassDecoratorContext
) {
Object.freeze(value);
Object.freeze(value.prototype);
console.log(`[FREEZE] Class "${context.name}" has been frozen.`);
}Using this class decorator prevents anyone from dynamically adding new properties or methods to the class at runtime:
import { freeze } from './decorators';
@freeze
class ConfigurationManager {
apiEndpoint = 'https://api.example.com';
}Adding Initializers with addInitializer
Standard decorators allow you to register initialization code using context.addInitializer. This is highly useful for auto-binding event handlers or registering classes in a dependency injection container.
Here is how you can use it to automatically bind a method to its class instance, preventing context errors when passing the method as a callback:
export function autobind(
value: Function,
context: ClassMethodDecoratorContext
) {
const methodName = context.name;
if (context.private) return;
context.addInitializer(function (this: any) {
this[methodName] = this[methodName].bind(this);
});
}Conclusion
TypeScript 5.x standard decorators bring clean, standard, and highly optimized metaprogramming to Javascript and TypeScript. By replacing legacy experimental features with standard signatures and context objects, they ensure future-proof runtime compatibility and strict type safety. Use them to write declarative utilities for logging, debugging, access control, and metadata tracking, keeping your core business logic clean and decoupled.