Right, decorators. You’ve probably heard the hype, seen the @ symbols littering frameworks, and wondered if it’s just magic glitter someone sprinkled on JavaScript. It’s not. It’s a structured, powerful, and frankly, a bit awkward way to metaprogram your classes. We’re going to focus on the ones that finally made it into the official spec: property and accessor decorators. Forget the old, weird, wildly-incompatible-between-Babel-and-TypeScript versions. ES2023 is the real deal, and it’s about time.

Let’s get one thing straight: a decorator is just a function. A function we apply to a class element to wrap its definition and potentially change its behavior. It feels like magic, but I promise you, it’s just functions all the way down.

The Anatomy of a Property Decorator

A property decorator function is called with two arguments: 1. the value of the property (always undefined for a standard field!) and 2. a context object. The context object is where the real info lives.

function myDecorator(value, context) {
  console.log(`Decorating ${context.kind} named '${context.name}'`);
  console.log(`Is it private? ${context.private}`);
  console.log(`Is it static? ${context.static}`);
}

class MyClass {
  @myDecorator
  myField = 42;
}
// Logs:
// Decorating field named 'myField'
// Is it private? false
// Is it static? false

Notice the value is undefined? That’s the first big “gotcha.” You don’t get the initializer’s value here. You’re running during class definition, not instance creation. The decorator’s job isn’t to see the value 42; it’s to potentially mess with the very definition of that field.

So what can you actually do? You can return a replacement descriptor, an object with get and set methods, to effectively replace the simple field with a full-fledged accessor. This is how you’d implement things like automatic type checking or logging.

function logged(value, context) {
  const { kind, name } = context;
  if (kind === 'field') {
    return function (initialValue) {
      console.log(`Initializing ${name} to ${initialValue}`);
      return initialValue;
    };
  }
}

class Person {
  @logged
  name = 'Alice';
}

const p = new Person(); // Logs: "Initializing name to Alice"

Wait, what? I just returned a function? Yes! This is the second “gotcha.” For field kinds, you can optionally return a replacement initializer function. This function receives the original initial value ('Alice') and whatever it returns becomes the new initial value for the field. It’s a way to wrap or transform the initialization process without the nuclear option of replacing the whole thing with a getter/setter pair.

Mastering Accessor Decorators

Accessor decorators (for get/set methods) are where things get more straightforward and, in my opinion, more useful. Their context object is richer, giving you hooks into the actual getter and setter functions.

function configurable({ kind, name, addInitializer }) {
  if (kind === 'accessor') {
    return function (initialValue) {
      return {
        get() {
          return initialValue.get.call(this);
        },
        set(value) {
          // Let's pretend we have some validation logic here
          console.log(`Setting ${name} to ${value}`);
          return initialValue.set.call(this, value);
        },
        init(initialValue) {
          return initialValue;
        }
      };
    };
  }
}

class Wizard {
  @configurable
  get mana() { return this._mana; }
  set mana(value) { this._mana = value; }
}

Here, the decorator receives the original getter and setter functions bundled into an object (initialValue). We then return a new object with our own get and set methods. This is the classic decorator pattern: we wrap the original function, add our own behavior (logging, in this case), and then delegate back to the original. The init method is another hook for transforming the initial value if the accessor is a field with a getter/setter.

Pitfalls and Sharp Edges

  1. Order of Application Matters: Decorators are applied from the inside out. The decorator closest to the field runs last. This can be crucial if one decorator depends on the work of another. It’s the opposite of how function composition works, so it’s easy to get backwards.
  2. addInitializer is Weird but Powerful: The context object has an addInitializer method. This lets you register a callback to run when the class itself is instantiated. It’s your only way to run code that has access to the fully-constructed this context for a field decorator. It’s powerful for setting up event listeners or other instance-specific setup, but the syntax is… unique.
    function bound({ kind, name, addInitializer }) {
      if (kind === 'method') {
        addInitializer(function () {
          this[name] = this[name].bind(this);
        });
      }
    }
    
  3. You Can’t Decorate Arbitrary Objects: This is a class-only feature. You can’t slap an @ on a property in a plain object and expect it to work. The runtime hooks for this are deeply tied to the class definition process.
  4. The value Argument is Useless for Fields: I said it already, but it’s the biggest trip-up. You’re decorating the definition, not the value.

The designers made a choice here: power and explicitness over convenience. It’s a bit more boilerplate than the old legacy decorators, but you’re never left guessing what’s happening. It’s all just functions and objects, no dark magic. And that, frankly, is how it should be.