Skip to main content

Introduction to TypeScript Decorators

TypeScript decorators are a practical tool that lets you change or extend the behavior of classes , methods, properties, and parameters. They are special functions that you apply using the @ syntax to add metadata, alter functionality, or completely transform the decorated element. Decorators have become a key feature in TypeScript, helping developers write code that is easier to maintain, adapt, and improve.

As of TypeScript 5, decorators are now a fully supported feature and no longer considered experimental. This means you don't need to use the --experimentalDecorators compiler flag anymore. However, it's worth noting that the new decorators standard in TypeScript 5 differs from the older implementation, so existing decorator code might need adjustments to work with the new syntax.

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 add extra information to your classes, methods, and properties, which can be useful for tasks like validation, logging, or injecting dependencies. Here’s how you can use a decorator to add metadata to a class:

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

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

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

You can also apply a decorator to add metadata to a method for more granular metadata management:

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

class MyClass {
@addMethodMetadata({ description: 'This is a test method' })
testMethod() {}
}

console.log(MyClass.prototype.testMethod.metadata); // Output: { description: 'This is a test method' }

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

You can create custom decorators to log function calls, which helps track how your code is executed. Here’s an example:

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

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

return descriptor;
}

class Calculator {
@logFunctionCalls
add(a: number, b: number) {
return a + b;
}
}

const calc = new Calculator();
console.log(calc.add(2, 3));
// Output:
// Calling method add with arguments: 2, 3
// Method add returned: 5
// 5

This pattern is useful when working with functions that require monitoring or debugging. For example, 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 combine different decorators on a single class or method. When using multiple decorators, they execute in reverse order (bottom-up). Here’s how to apply multiple decorators to a class:

function decorator1(target: any) {
console.log('Decorator 1 applied');
}

function decorator2(target: any) {
console.log('Decorator 2 applied');
}

@decorator1
@decorator2
class MyClass {}

// Output: Decorator 2 applied, Decorator 1 applied

And here’s how you can apply multiple decorators to a method:

function decorator1(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
console.log('Decorator 1 applied to method');
}

function decorator2(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
console.log('Decorator 2 applied to method');
}

class MyClass {
@decorator1
@decorator2
testMethod() {}
}

// Output: Decorator 2 applied to method, Decorator 1 applied to method

This decorator composition pattern enables advanced behavior combinations in classes. When working with Convex, you can use similar patterns to combine different validation decorators on your data models.

Validating Method Input Parameters

Decorators can be used to ensure that methods receive the correct input. Here’s how to create a decorator for input validation:

function validateParameters(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
// Validate input parameters
if (args.length !== 2) {
throw new Error('Invalid number of arguments');
}
if (typeof args[0] !== 'number' || typeof args[1] !== 'number') {
throw new Error('Invalid argument types');
}
return originalMethod.apply(this, args);
};
return descriptor;
}

class MyClass {
@validateParameters
testMethod(a: number, b: number) {
return a + b;
}
}

const myClass = new MyClass();
try {
console.log(myClass.testMethod(2, '3')); // Throws an error
} catch (error) {
console.error(error.message); // Output: Invalid argument types
}

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.

Using Decorators for Dependency Injection

Decorators offer an elegant solution for dependency injection, helping manage object dependencies without tight coupling. Here’s an 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.

Debugging TypeScript Decorators

Debugging decorators can be tricky since they operate at the metadata level, but there are ways to track down issues. Here’s an example:

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

class MyClass {
@logFunctionCalls
testMethod(a: number, b: number) {
return a + b;
}
}

const myClass = new MyClass();
try {
console.log(myClass.testMethod(2, '3')); // Throws an error
} catch (error) {
console.error(error.message); // Output: Cannot read property 'apply' of undefined
}

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

Setting Up TypeScript for Experimental Decorators

TypeScript 5 introduced decorators as a stable feature using the ECMAScript decorator standard, removing the need for special compiler flags. However, if you're using an older TypeScript version or working with legacy decorator code, you'll need to enable experimental decorators in your tsconfig.json:

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

If you're using TypeScript 5+, you can use the new decorator syntax without these flags:

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

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

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

The new decorator system is more type-safe and aligns with the JavaScript standard. When using frameworks like Convex, check their documentation for compatibility with different decorator implementations.

Final Thoughts

TypeScript decorators add metadata, modify behavior, and manage dependencies with minimal code. They separate cross-cutting concerns from business logic, making your codebase cleaner and more maintainable.