17.2 Class Decorators: Wrapping and Replacing Constructors
Alright, let’s get our hands dirty with the two main flavors of class decorators: wrapping and replacing. This is where the magic happens, and also where you can spectacularly blow your own foot off if you’re not careful. I’m here to make sure you keep all your toes.
The core idea is simple: a class decorator receives the entire class constructor as its target and can either return a new constructor (wrapping or replacing) or mutate the original one. We’re going to focus on the non-mutating, return-a-new-constructor approach because it’s cleaner, more powerful, and frankly, less likely to cause weird side-effects that make you question your life choices.
The Anatomy of a Replacement
Let’s start with the nuclear option: full constructor replacement. You get a constructor function, and you return a completely different one. This is incredibly powerful for creating proxy classes or providing alternative implementations. The decorator function is passed the original target (the class constructor) and should return a new constructor.
function withAlternativeImplementation(target) {
// Return a COMPLETELY different class
return class AlternativeClass {
constructor() {
// Ignore the original target entirely? You monster.
this.engine = 'V8';
this.truth = 42;
}
introduce() {
console.log(`I am a lie, but a powerful one. ${this.truth}`);
}
};
}
@withAlternativeImplementation
class Car {
constructor() {
this.engine = 'V4';
}
}
const myCar = new Car();
myCar.introduce(); // "I am a lie, but a powerful one. 42"
console.log(myCar.engine); // "V8"
See what happened there? The original Car class is gone. Completely supplanted by AlternativeClass. This is both terrifying and awesome. Use it for patterns like creating a debug version of a class, but for the love of structured programming, don’t do it without a clear, well-documented reason. Swapping the very essence of what a class is behind a decorator is a surefire way to create code that behaves like a logical paradox for anyone else trying to read it.
The More Sensible Approach: Wrapping
More often, you don’t want to replace the class; you want to augment it. This is where wrapping the constructor comes in. You create a new class that extends the original target. This is less invasive and generally safer. You get all the benefits of the original class plus your new behavior.
The classic example, which you’ve probably seen, is adding logging.
function withLogging(target) {
// Return a new class that extends the original
return class extends target {
constructor(...args) {
super(...args);
console.log(`A new ${target.name} was instantiated with arguments:`, args);
}
};
}
@withLogging
class ApiClient {
constructor(apiKey, baseUrl) {
this.apiKey = apiKey;
this.baseUrl = baseUrl;
}
}
const client = new ApiClient('key-123', 'https://api.example.com');
// Console: "A new ApiClient was instantiated with arguments: ['key-123', 'https://api.example.com']"
This is far more useful. The class extends target syntax is your best friend here. It ensures the prototype chain remains intact. The new class can access all the original methods and properties. Notice how we use ...args to pass all arguments through to the original constructor seamlessly. This is crucial for making your decorator generic and reusable.
The Devil’s in the Details: new.target and You
Here’s the first major “gotcha.” When you wrap a class by returning a new one that extends the original, you’ve changed the inheritance structure. Inside the original constructor, the new.target meta-property (which tells you which constructor was actually called with new) will now point to your wrapper class, not the original class the author wrote.
function withLogging(target) {
return class extends target {
constructor(...args) {
super(...args);
console.log(`Instantiated. new.target is ${new.target.name}`);
}
};
}
@withLogging
class Example {
constructor() {
console.log(`Inside Example constructor. new.target is ${new.target.name}`);
}
}
new Example();
// Console: "Inside Example constructor. new.target is withLoggingClass"
// Console: "Instantiated. new.target is withLoggingClass"
For 99% of classes, this doesn’t matter. But if the original class had logic that relied on new.target (e.g., to make an abstract class), that logic is now broken. There’s no clean way to fix this from the decorator, which is why you must be aware of it. It’s a sharp edge left by the designers, and you just have to know it’s there.
Best Practice: Preserving the Class Name
Look at the console output above. The wrapped class has a useless name like withLoggingClass. This is awful for debugging. We can fix this by using Object.defineProperty to set the name descriptor on the returned class to match the original.
function withLogging(target) {
const WrappedClass = class extends target {
constructor(...args) {
super(...args);
console.log(`Instantiated ${target.name}`);
}
};
// This is a bit verbose, but it's essential for good DX.
Object.defineProperty(WrappedClass, 'name', {
value: target.name,
writable: false
});
return WrappedClass;
}
@withLogging
class ApiClient { /* ... */ }
console.log(ApiClient.name); // "ApiClient" - Much better!
It’s a few extra lines, but it makes your decorated classes behave like proper citizens in the JavaScript ecosystem. Always do this. Your fellow developers (and your future self) will thank you when they’re looking at a stack trace.