Skip to main content

Introduction to TypeScript Decorators

You're debugging a production issue where certain API calls are failing intermittently. After hours of digging, you discover the problem: there's no logging around these critical methods, so you can't see what arguments were passed or when they were called. Now you need to add logging to dozens of methods across multiple classes. You could copy-paste logging code into each method, but that's tedious and error-prone.

This is where decorators shine. They let you add behavior like logging, validation, or caching to methods, properties, and classes by simply adding a @decorator above them. Instead of cluttering your business logic with repetitive code, you write the behavior once and apply it anywhere with a single line.

Decorators are functions that modify or extend the behavior of classes, methods, properties, and parameters. You apply them using the @ syntax, and they execute when your code is loaded, transforming the decorated element before it's used.

TypeScript 5 and the New Decorator Standard

As of TypeScript 5, decorators are now a stable language feature following the ECMAScript Stage 3 proposal. You don't need the --experimentalDecorators compiler flag anymore unless you're maintaining legacy code. The new standard brings better type safety and aligns TypeScript with JavaScript's decorator specification.

However, the TypeScript 5 decorator implementation differs from the older experimental version. If you have existing decorator code using the experimental syntax, you'll need to refactor it to work with the new standard. We'll cover the setup later in this guide.

When working with frameworks like Convex, decorators can be particularly useful for adding type safety and validation to your backend functions.

Adding Metadata with TypeScript Decorators

Decorators can attach extra information to your classes, methods, and properties. This is useful for documentation, validation rules, or dependency injection metadata. Here's a class decorator that adds version information:

function addMetadata(metadata: any) {
return (target: any) => {
target.metadata = metadata;
};
}

@addMetadata({ author: 'John Doe', version: '1.0' })
class ApiClient {}

console.log(ApiClient.metadata); // Output: { author: 'John Doe', version: '1.0' }

You can also apply decorators to methods for more granular control:

function addMethodMetadata(metadata: any) {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
descriptor.metadata = metadata;
};
}

class DataService {
@addMethodMetadata({ rateLimit: 100, cacheable: true })
fetchUserData() {}
}

console.log(DataService.prototype.fetchUserData.metadata);
// Output: { rateLimit: 100, cacheable: true }

When building applications with Convex, you can use similar patterns to add metadata to your server functions for documentation or validation purposes.

Custom Decorators for Function Logging

Method decorators can automatically log function calls, which is invaluable for debugging and monitoring. Here's a practical logging decorator:

function logFunctionCalls(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;

descriptor.value = function (...args: any[]) {
console.log(`Calling ${propertyKey} with arguments: ${args.join(', ')}`);
const result = originalMethod.apply(this, args);
console.log(`${propertyKey} returned: ${result}`);
return result;
};

return descriptor;
}

class PaymentProcessor {
@logFunctionCalls
processPayment(amount: number, userId: string) {
// Process payment logic
return `Processed $${amount} for user ${userId}`;
}
}

const processor = new PaymentProcessor();
processor.processPayment(99.99, 'user_123');
// Output:
// Calling processPayment with arguments: 99.99, user_123
// processPayment returned: Processed $99.99 for user user_123

This pattern works well with functions that require monitoring or debugging. When building applications with Convex, you could create similar decorators to monitor your custom functions' behavior.

Using Multiple Decorators on a Class or Method

You can stack multiple decorators on a single class or method to combine different behaviors. When you do this, they execute in reverse order (bottom-up). The decorator closest to the class or method runs first:

function logExecution(target: any) {
console.log('Logging decorator applied');
}

function trackPerformance(target: any) {
console.log('Performance decorator applied');
}

@logExecution
@trackPerformance
class DataService {}

// Output: Performance decorator applied, Logging decorator applied

Here's a more practical example with method decorators:

function authorize(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log('Checking authorization...');
return originalMethod.apply(this, args);
};
return descriptor;
}

function validate(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log('Validating inputs...');
return originalMethod.apply(this, args);
};
return descriptor;
}

class UserService {
@authorize
@validate
deleteUser(userId: string) {
return `Deleted user ${userId}`;
}
}

// When called, validate runs first, then authorize

This decorator composition pattern lets you build complex behaviors from simple, reusable pieces. When working with Convex, you can use similar patterns to combine different validation decorators on your data models.

Validating Method Input Parameters

Decorators give you runtime type checking on top of TypeScript's compile-time checks. This is particularly useful when dealing with external data sources or APIs where you can't trust the types at runtime:

function validateNumbers(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
// Validate that all arguments are numbers
if (!args.every(arg => typeof arg === 'number')) {
throw new Error(`${propertyKey} requires all arguments to be numbers`);
}
return originalMethod.apply(this, args);
};
return descriptor;
}

class PricingCalculator {
@validateNumbers
calculateTotal(basePrice: number, taxRate: number, discount: number) {
return basePrice * (1 + taxRate) - discount;
}
}

const calculator = new PricingCalculator();
try {
// This will throw at runtime even if it passed TypeScript's type checking
console.log(calculator.calculateTotal(100, '0.15' as any, 10));
} catch (error) {
console.error(error.message); // Output: calculateTotal requires all arguments to be numbers
}

This validation pattern works well with interface definitions by ensuring runtime type safety. For backend validation in Convex applications, you can check out argument validation techniques that complement decorator-based approaches.

Accessor Decorators for Getters and Setters

Accessor decorators let you intercept property access through getters and setters. They're perfect for validation, logging, or controlling how properties are read and written. Here's a practical example that validates values before setting them:

function validateRange(min: number, max: number) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalSet = descriptor.set;

descriptor.set = function (value: number) {
if (value < min || value > max) {
throw new Error(`${propertyKey} must be between ${min} and ${max}`);
}
originalSet?.call(this, value);
};

return descriptor;
};
}

class Product {
private _discount: number = 0;

@validateRange(0, 100)
set discount(value: number) {
this._discount = value;
}

get discount() {
return this._discount;
}
}

const product = new Product();
product.discount = 25; // Works fine
product.discount = 150; // Throws: discount must be between 0 and 100

You can also use accessor decorators to control property enumerability, which affects whether a property shows up in Object.keys() or for...in loops:

function enumerable(value: boolean) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
descriptor.enumerable = value;
return descriptor;
};
}

class User {
public name: string = 'John';

@enumerable(false)
get internalId() {
return 'hidden_value';
}
}

const user = new User();
console.log(Object.keys(user)); // Output: ['name'] (internalId is not enumerable)

Note that TypeScript doesn't allow decorating both the getter and setter for the same property. You only need to decorate the first accessor (usually the getter), and the decorator will apply to both.

Using Decorators for Dependency Injection

Decorators offer a clean solution for dependency injection, helping you manage object dependencies without tight coupling. Here's a simple but practical example:

// Service interfaces
interface LoggerService {
log(message: string): void;
}

interface DatabaseService {
save(data: any): void;
}

// Service implementations
class ConsoleLogger implements LoggerService {
log(message: string): void {
console.log(`[LOG]: ${message}`);
}
}

class MockDatabase implements DatabaseService {
save(data: any): void {
console.log(`[DB]: Saved ${JSON.stringify(data)}`);
}
}

// Simple dependency container
class Container {
private static services: Map<string, any> = new Map();

static register(key: string, implementation: any): void {
Container.services.set(key, implementation);
}

static get(key: string): any {
return Container.services.get(key);
}
}

// Register services
Container.register('logger', new ConsoleLogger());
Container.register('database', new MockDatabase());

// Inject decorator
function Inject(serviceKey: string) {
return function(target: any, propertyKey: string) {
// Define property getter
Object.defineProperty(target, propertyKey, {
get: function() {
return Container.get(serviceKey);
}
});
};
}

// Usage in a class
class UserService {
@Inject('logger')
private logger!: LoggerService;

@Inject('database')
private db!: DatabaseService;

createUser(name: string, email: string) {
this.logger.log(`Creating user: ${name}`);
this.db.save({ name, email });
return { success: true };
}
}

// Use the service
const userService = new UserService();
userService.createUser('John', 'john@example.com');
// Output:
// [LOG]: Creating user: John
// [DB]: Saved {"name":"John","email":"john@example.com"}

This pattern enables clean separation of concerns in class implementations. When working with Convex, you can use similar dependency injection techniques to make your code more testable.

Performance Optimization with Memoization Decorators

Decorators can boost performance by caching expensive function results. Here's a memoization decorator that caches method results based on arguments:

function memoize(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
const cache = new Map<string, any>();

descriptor.value = function (...args: any[]) {
const cacheKey = JSON.stringify(args);

if (cache.has(cacheKey)) {
console.log(`Cache hit for ${propertyKey}`);
return cache.get(cacheKey);
}

console.log(`Cache miss for ${propertyKey}, computing...`);
const result = originalMethod.apply(this, args);
cache.set(cacheKey, result);
return result;
};

return descriptor;
}

class DataAnalyzer {
@memoize
calculateComplexMetric(datasetId: string, options: any) {
// Simulate expensive computation
console.log('Performing expensive calculation...');
return `Result for ${datasetId} with options ${JSON.stringify(options)}`;
}
}

const analyzer = new DataAnalyzer();
analyzer.calculateComplexMetric('dataset1', { threshold: 0.5 }); // Cache miss, computes
analyzer.calculateComplexMetric('dataset1', { threshold: 0.5 }); // Cache hit, returns cached result

For production use, you'll want a more sophisticated caching strategy that handles cache invalidation, size limits, and potentially async operations. Keep in mind that memoization only helps when your methods are pure functions (same inputs always produce the same outputs).

Common Mistakes to Avoid

When working with decorators, watch out for these common issues that can cause bugs or unexpected behavior.

Wrong Decorator Signatures

Each decorator type has a specific signature. Using the wrong parameters will cause errors:

// ❌ Wrong: Method decorator missing descriptor parameter
function brokenDecorator(target: any, propertyKey: string) {
// This won't work as a method decorator
}

// ✓ Correct: Method decorator with all required parameters
function workingDecorator(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
return descriptor;
}

Forgetting to Return the Descriptor

Method decorators must return the property descriptor, or your modifications won't take effect:

// ❌ Wrong: No return value
function brokenLog(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function(...args: any[]) {
console.log('Called');
return original.apply(this, args);
};
// Missing return!
}

// ✓ Correct: Returns the descriptor
function workingLog(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function(...args: any[]) {
console.log('Called');
return original.apply(this, args);
};
return descriptor; // Don't forget this
}

Losing this Context

Be careful with arrow functions in decorators. They can break the this context:

// ❌ Wrong: Arrow function captures wrong context
function brokenDecorator(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = (...args: any[]) => { // Arrow function breaks 'this'
return original.apply(this, args);
};
return descriptor;
}

// ✓ Correct: Regular function preserves context
function workingDecorator(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function(...args: any[]) { // Regular function works
return original.apply(this, args);
};
return descriptor;
}

Decorator Execution Order Confusion

Remember that decorators execute bottom-up (closest to the target first), but their factories execute top-down:

function first() {
console.log('first(): factory evaluated');
return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
console.log('first(): decorator executed');
};
}

function second() {
console.log('second(): factory evaluated');
return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
console.log('second(): decorator executed');
};
}

class Example {
@first()
@second()
method() {}
}

// Output:
// first(): factory evaluated
// second(): factory evaluated
// second(): decorator executed
// first(): decorator executed

For more complex debugging scenarios, you can use Chrome DevTools or the Node.js debugger to set breakpoints inside decorator functions. The type system also helps catch many issues at compile time.

Setting Up TypeScript for Decorators

If you're on TypeScript 5 or later, decorators work out of the box with no configuration needed. The new standard decorators are enabled by default and don't require any special compiler flags.

However, if you're maintaining legacy code or working with frameworks that still use the old experimental decorators (like older versions of Angular or NestJS), you'll need to enable them in your tsconfig.json:

{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}

Should You Use Standard or Experimental Decorators?

Here's the key difference: the TypeScript 5 standard decorators follow the ECMAScript Stage 3 proposal and can't change the kind of a class member (you can't turn a property into a method, for example). The old experimental decorators had more flexibility but don't align with the JavaScript standard.

For new projects, use the standard decorators. They're type-safe, well-documented, and future-proof. Only use experimental decorators if you're working with a framework that requires them or maintaining existing code.

When using frameworks like Convex, check their documentation for compatibility with different decorator implementations, especially if you're upgrading from an older TypeScript version.

Key Takeaways

Decorators let you add reusable behavior to classes, methods, and properties without cluttering your business logic. Here's what to remember:

  • Use decorators for cross-cutting concerns like logging, validation, authorization, and caching
  • Method decorators receive three parameters: target, property key, and property descriptor
  • Stack multiple decorators to combine behaviors (they execute bottom-up)
  • Accessor decorators apply to getters and setters but can only be applied to the first accessor
  • Always return the descriptor from method and accessor decorators
  • Use regular functions, not arrow functions, to preserve this context
  • TypeScript 5+ supports standard decorators without configuration flags

Decorators shine when you need to apply the same behavior across multiple methods or classes. Instead of copy-pasting validation logic into ten different methods, write one decorator and apply it wherever needed. This keeps your code DRY and makes changes easier since you only need to update the decorator.