17.3 Method Decorators: Intercepting Method Calls
Alright, let’s get our hands dirty with method decorators. Forget the dry theory—you want to know what these things do. At their core, a method decorator is a function that gets to wrap another function, specifically a method on a class. It can intercept the call, mess with the inputs, mess with the output, or even replace the entire method. It’s like having a universal middleware system for your class methods, and it’s incredibly powerful for things like logging, validation, or access control without cluttering your actual business logic.
Here’s the basic signature. Don’t panic; we’ll break it down.
type MethodDecorator = (
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor
) => void | PropertyDescriptor;
The key player here is the descriptor object. This is the same old PropertyDescriptor from JavaScript’s Object.defineProperty, and it’s your gateway to the method itself. The value property of the descriptor is the original method. To intercept the call, you write a function that wraps it.
The Anatomy of a Simple Decorator
Let’s build a dead-simple one: a decorator that logs the name of the method and the arguments passed to it. This is your “hello world” for method decorators.
function logCall(target: any, methodName: string, descriptor: PropertyDescriptor) {
// Grab the original method
const originalMethod = descriptor.value;
// Replace it with a new function that wraps the original
descriptor.value = function (...args: any[]) {
console.log(`Calling ${methodName} with arguments:`, args);
// The magic: call the original method in its original context (`this`)
const result = originalMethod.apply(this, args);
console.log(`Method ${methodName} returned:`, result);
return result;
};
// You must return the modified descriptor for this to work properly
return descriptor;
}
class DataService {
@logCall
fetchData(id: number) {
// Simulate some async work
return Promise.resolve(`Data for id ${id}`);
}
}
const service = new DataService();
service.fetchData(42);
// Console output:
// Calling fetchData with arguments: [42]
// Method fetchData returned: Promise { <state>: "fulfilled", <value>: "Data for id 42" }
See what happened? The @logCall decorator didn’t just run once; it modified the fetchData method at class definition time, replacing it with our wrapper function. Every subsequent call to fetchData goes through our logging code first. The crucial bit is originalMethod.apply(this, args). Using apply ensures the original method is called with the correct this context (the instance of DataService). If you used originalMethod(...args) instead, this would be undefined inside fetchData—a classic and frustrating pitfall.
Preserving the this Context is Non-Negotiable
I just mentioned the this pitfall, but it’s so important it deserves its own section. Messing this up is the number one cause of decorators that mysteriously break. The wrapper function must be a regular function, not an arrow function. Why? Because arrow functions lexically bind this, stealing it from the method call. The wrapper needs to be a dynamic function so that when it’s called as instance.method(), this inside the wrapper correctly points to instance.
Wrong (Arrow Function):
descriptor.value = (...args: any[]) => { // `this` is undefined here!
console.log(`Calling ${methodName}`);
return originalMethod.apply(this, args); // Error: Cannot read properties of undefined
};
Right (Function Expression):
descriptor.value = function (...args: any[]) { // `this` is dynamically scoped
console.log(`Calling ${methodName}`);
return originalMethod.apply(this, args); // `this` is correct!
};
Handling Asynchronous Methods Gracefully
The modern world is async, and your decorators need to be ready. The beautiful thing is, our basic decorator structure already works for async methods and methods returning promises because it simply returns the result. But what if you need to do something after the promise resolves? You need to await it.
function measureTime(target: any, methodName: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const start = performance.now();
const result = await originalMethod.apply(this, args); // Await the original call
const end = performance.now();
console.log(`'${methodName}' executed in ${(end - start).toFixed(2)}ms`);
return result;
};
return descriptor;
}
class ApiClient {
@measureTime
async getUser(id: string) {
// Simulate a network request
await new Promise(resolve => setTimeout(resolve, 100));
return { id, name: "Alice" };
}
}
The key change is making the wrapper function async and await-ing the result of the original method. This ensures the timing is measured correctly after the operation finishes, not when the promise is created.
The Quirks: Property Descriptor and Metadata
Here’s a slightly absurd edge case that JavaScript forces upon us: if the decorated method is an arrow function defined on the class, this whole scheme falls apart. Why? Because class arrow functions are assigned to the instance in the constructor, not to the prototype. Our decorators operate on the prototype. So an arrow function won’t be in the descriptor you receive. The designers gave us a powerful tool that, frankly, just doesn’t work with a common pattern. It’s a conscious trade-off. If you need to decorate a method, use the regular method() {} syntax.
Also, note that the decorator receives the current descriptor, which might have been modified by a previous decorator. Decorators compose, and they are applied in a bottom-to-top order (from the method outward). This is incredibly powerful for building complex behavior from simple, single-purpose decorators.