Skip to Content
Course content

195: Accessor Decorators

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this exact bug happen a dozen times when developers first move from simple property decorators to accessor decorators. You want to add a bit of side-effect logic—like logging or validation—to a property, so you wrap the setter. It looks correct on paper, but the second you run it, the whole app crashes with a TypeError.

function LogChange(target: any, key: string, descriptor: PropertyDescriptor) {
    const originalSet = descriptor.set;

    descriptor.set = function(value: any) {
        console.log(`Changing ${key} to ${value}`);
        // This looks fine, right?
        originalSet(value); 
    };
}

class UserProfile {
    private _username: string = "Guest";

    @LogChange
    set username(val: string) {
        this._username = val;
    }

    get username() {
        return this._username;
    }
}

const user = new UserProfile();
user.username = "Alice"; // ❌ TypeError: Cannot read property '_username' of undefined

The disappearing 'this' context

If you try to run that code, you'll see the log message appear, but then it immediately crashes. The problem is a classic JavaScript gotcha: execution context. When we saved descriptor.set into the variable originalSet, we stripped it away from the UserProfile instance. When we call originalSet(value) inside our new function, this is no longer pointing to our user object; it's either undefined (in strict mode) or the global window object.

Since the original setter relies on this._username to actually save the data, it blows up because this is missing.

Restoring context with .call()

To fix this, we need to explicitly tell JavaScript which object this should refer to when the original setter runs. We do this using the .call()` method, passing this (the current instance) as the first argument.

function LogChange(target: any, key: string, descriptor: PropertyDescriptor) {
    const originalSet = descriptor.set;

    descriptor.set = function(value: any) {
        console.log(`Changing ${key} to ${value}`);
        // We explicitly bind 'this' back to the instance
        originalSet.call(this, value); 
    };
}

Now, when user.username = "Alice" is called, our decorator function executes, and originalSet.call(this, value) ensures that the original setter receives the user instance as its context. The value is saved, the log is printed, and the app doesn't crash.

How Accessor Decorators actually hook in

It's important to realize that accessor decorators are specifically designed for getters and setters. They differ from property decorators (which target a field) because they give you direct access to the PropertyDescriptor.

The PropertyDescriptor is essentially the "blueprint" for how that property behaves. It contains:

  • get: The function that runs when you read the property.
  • set: The function that runs when you assign a value to the property.
  • configurable: Whether the property can be deleted or changed.
  • enumerable: Whether the property shows up during for...in loops.

I usually recommend accessor decorators when you need to intercept a value. If you just need to add metadata to a field (like @Required for a form), a property decorator is enough. But if you want to transform a value before it's saved, or prevent a value from being set based on some logic, you need the accessor decorator to wrap the get or set functions.

One quick tip: always check if descriptor.set or descriptor.get actually exists before trying to wrap them. If you accidentally put an accessor decorator on a property that only has a getter, trying to wrap the (non-existent) setter will throw an error before your code even starts running.




📋 Practical Task

Building a Non-Negative Price Validator

You are building an e-commerce system where product prices can never be negative. Instead of writing if (val < 0) inside every single setter in your codebase, you've been asked to create a reusable accessor decorator.

Your Task:

  1. Create a decorator named @NonNegative.
  2. This decorator should target a setter.
  3. If the value being set is less than 0, the decorator should console.error("Price cannot be negative!") and not call the original setter.
  4. If the value is 0 or greater, it should call the original setter using the correct this context.
  5. Apply this decorator to the price setter in the Product class provided below.
class Product {
    private _price: number = 0;

    // Add your decorator here
    set price(value: number) {
        this._price = value;
    }

    get price() {
        return this._price;
    }
}

const shirt = new Product();
shirt.price = 25;  // Should work
shirt.price = -10; // Should log error and NOT update the price
console.log(shirt.price); // Should still be 25
Rating
0 0

There are no comments for now.

to be the first to leave a comment.