A Brief History of Decorators in JavaScript

JavaScript doesn't support decorators, not officially, not yet. But they are planned for to the language, currently at stage 3 (of 4) in the new feature proposal process. For more information see the decorator proposal. At stage 3, the current proposal is very likely what you can expect to see implemented in runtimes in the near future.

Here, we'll look into the changes of the decorators spec over its last couple of revisions, from where it started (what's out there now) to where it ended up.

What are Decorators?

Decorators are custom modifiers, similar to those you might see for functions like static or async, that end-users can create and apply to various definitions within their code. Decorators can modify, or "decorate", anything from classes to variables. Initial support for decorators will be limited to class definitions (classes and their members), but are planned to be expanded to include other use cases later on.

A example of a decorator would be an @enumerable decorator that could expose a class's method to enumeration.

class MyClass {
  
  @enumerable // decorator applied to the exposed method
  exposed () {}
}

const myInstance = new MyClass()
for (let member in myInstance) console.log(member) // "exposed"

Normally methods do not get exposed to iteration through for...in loops. However, here, the @enumerable decorator was able to alter the implementation of the exposed method so that it would.

Iteration 1: Legacy Decorators

The first iteration of decorators was the simplest and, currently, is still the most widely used. You'll see this implementation, or a variation of it, in TypeScript and used by libraries like MobX.

Legacy decorators have the simplest implementation. They use normal JavaScript functions as decorators and are able to decorate both classes and the members defined within them. Class decorators simply wrap the class in a function while method and accessor decorators get passed the class prototype, the name of the member, and an object descriptor (as used with Object.defineProperty) for that member as arguments. The @enumerable decorator from earlier, given that it decorates a class method, could be defined as:

function enumerable (target, key, descriptor) {
    descriptor.enumerable = true
}

When applied to a class method, placing the function before the definition with an @ character prefix in its name...

class MyClass {
  
  @enumerable
  exposed () {}
}

the function gets run as:

const descriptor = Object.getOwnPropertyDescriptor(MyClass.prototype, 'exposed')
enumerable(MyClass.prototype, 'exposed', descriptor)

Any changes to the descriptor then gets applied back to the definition, or if a new descriptor was returned, that would be used in its place.

The simplicity of these kinds of decorators made them easy implement, and even as simple as they were, they provided enough functionality to handle most of the common use cases. Unfortunately, the feature set of classes was expanding (for example, with the inclusion of private members) and decorators needed to catch up.

Iteration 2: Enhanced Decorators

The next iteration of the decorators specification greatly expanded the capabilities of legacy decorators. The overall approach was very similar, still using functions to represent decorators, but the improved design allowed for much more than was possible before.

These new, enhanced decorators took the normal object descriptor used by the legacy decorators and super charged it, added additional properties that provided access to virtually every part of the definition. Additions include:

Using the placement property, for example, you could take a method that would normally get placed on the prototype and instead define it on the instance by changing its value to "own".

Also introduced was the concept of hooks. Hooks are callbacks that can be run at different times during the definition process. A normal decorator can be transformed into a hook by adding a hook callback to the descriptor, or they can be added separately to a decoration by adding them to the extras array.

Looking back to the @enumerable decorator example, we can update it for this new iteration:

function enumerable (descriptor) {
    descriptor.enumerable = true
    return descriptor
}

The definition has changed slightly since all of the information for the decorator is encapsulated in the new descriptor object which also now needs to be returned.

While the @enumerable example doesn't do much to show off the added functionality of these new decorators, the additional complexity needed to support this added functionality did come at a cost - a cost that was to be addressed in the next iteration.

Iteration 3: Static Decorators

The added complexity in the second iteration of decorators not only made them more complicated to author, but also affected decorator performance. The third, and current iteration of decorators addressed these issues by taking a step back, largely reverting to the functionality of the original, legacy decorators (with a few exceptions) and changing how they were defined to make them more statically analyzable.

One of the largest impacts of this change is that decorators are no longer simple JavaScript functions. They are, instead, a brand new entity with their own declarations that are created with the use of a new decorator keyword. Decorators defined this way are also nothing more than a composition of other decorators, either other custom decorators and/or any of the built-in decorator primitives that is to be provided by the language.

Built-in decorators include:

The implementation of the @enumerable example with static decorators would be:

decorator @enumerable {
  @register((target, name) => {
    const descriptor = Object.getOwnPropertyDescriptor(target, name)
    Object.defineProperty(target, name, { ...descriptor, enumerable: true })
  })
}

No longer is enumerable just a function. It's now a new kind of declaration created with the new decorator keyword with the identity @enumerable (with the @ character included). It wraps the @register primitive decorator including the code necessary to alter the enumerability of the decorated member. In doing so, you may also notice that the descriptor is no longer being provided and has to be retrieved and set manually.

The application of decorators have not changed.

class MyClass {
  
  @enumerable
  exposed () {}
}

However, since user-defined decorators are now just wrappers for the built-in primitives, @enumerable could just as well have been written as:

class MyClass {
  
  @register((target, name) => {
    const descriptor = Object.getOwnPropertyDescriptor(target, name)
    Object.defineProperty(target, name, { ...descriptor, enumerable: true })
  })
  exposed () {}
}

This iteration was eventually abandoned for a version closer to the original legacy decorator design, most likely to help improve the developer experience. Changes are still being made as the proposal is not as of yet finalized.

Iteration 4: Proposed Decorators

The current stage 3 version of decorators is expected to be the final version that will ultimately get added to the specification when the proposal process is complete. This version of decorators backs away from the static approach and returns to an approach more similar to the legacy implementation. Decorators are once again implemented as simple JavaScript functions and get passed a target that represents what they're decorating along with some additional information about that target.

With this iteration, however, the target is not the containing object along with the name of the decorated element. Instead the target is the element itself. So if a method is being decorated, the target is the method function, not the class prototype and the method name. Similarly, a descriptor is not provided. In place of a descriptor is a context object providing information and some utilities relating to the decorated element.

The lack of a descriptor makes an enumerable decorator a little more difficult with this design. To help, a returning feature from iteration 2 is an initialization hook which can be set up from the provided context object. Adding an initializer lets you run code after a class instance is created which would be something similar to the finish hook from iteration 2.

function enumerable (target, context) {
    context.addInitializer(function () {
        Object.defineProperty(this.constructor.prototype, context.name, { enumerable: true })
    })
}

There is one notable downside to this implementation. That is this code will run for every instance during construction even though it is only necessary to define the enumerable property once on the prototype. While this still works, it is not ideal. In order to be able to have this only run once, you'd need an additional class decorator that would provide the hook using properties flagged by individual element decorators such as the one above. This kind of coordination makes (efficient) descriptor-based changes for methods more difficult to both implement and apply.

Summary

Comparing the decorator iterations:

Legacy Decorators

Enhanced Decorators

Static Decorators

Proposed Decorators

References