JavaScript metaprogramming with the 2022-03 decorators API (original) (raw)

JavaScript decorators have finally reached stage 3! Their latest version is already supported by Babel and will soon be supported by TypeScript.

This blog post covers the 2022-03 version (stage 3) of the ECMAScript proposal “Decorators” by Daniel Ehrenberg and Chris Garrett.

A decorator is a keyword that starts with an @ symbol and can be put in front of classes and class members (such as methods). For example, @trace is a decorator:

class C {
  @trace
  toString() {
    return 'C';
  }
}

A decorator changes how the decorated construct works. In this case, every invocation of .toString() will be “traced” (arguments and result will be logged to the console). We’ll see how @trace is implemented later.

Decorators are mostly an object-oriented feature and popular in OOP frameworks and libraries such as Ember, Angular, Vue, web component frameworks and MobX.

There are two stakeholders when it comes to decorators:

This blog post is intended for library authors: We’ll learn how decorators work and use our knowledge to implement several of them.

The history of decorators (optional section)

(This section is optional. If you skip it, you can still understand the remaining content.)

Let’s start by looking at the history of decorators. Among others, two questions will be answered:

The history of decorators

The following history describes:

This is a chronological account of relevant events:

It took a long time to reach stage 3 because it was difficult to get all stakeholders to agree on an API. Concerns included interactions with other features (such as class members and private state) and performance.

The history of Babel’s decorator implementation

Babel closely tracked the evolution of the decorator proposal, thanks to the efforts of Logan Smyth, Nicolò Ribaudo and others:

What are decorators?

Decorators let us change how JavaScript constructs (such as classes and methods) work. Let’s revisit our previous example with the decorator @trace:

class C {
  @trace
  toString() {
    return 'C';
  }
}

To implement @trace, we only have to write a function (the exact implementation will be shown later):

function trace(decoratedMethod) {
  // Returns a function that replaces `decoratedMethod`.
}

The class with the decorated method is roughly equivalent to the following code:

class C {
  toString() {
    return 'C';
  }
}
C.prototype.toString = trace(C.prototype.toString);

In other words: A decorator is a function that we can apply to language constructs. We do so by putting @ plus its name in front of them.

Writing and using decorators is metaprogramming:

For more information on metaprogramming, see section “Programming versus metaprogramming” in “Deep JavaScript”.

The shape of decorator functions

Before we explore examples of decorator functions, I’d like to take a look at their TypeScript type signature:

type Decorator = (
  value: DecoratedValue, // only fields differ
  context: {
    kind: string;
    name: string | symbol;
    addInitializer(initializer: () => void): void;

    // Don’t always exist:
    static: boolean;
    private: boolean;
    access: {get: () => unknown, set: (value: unknown) => void};
  }
) => void | ReplacementValue; // only fields differ

That is, a decorator is a function. Its parameters are:

Property .kind tells the decorator which kind of JavaScript construct it is applied to. We can use the same function for multiple constructs.

Currently, decorators can be applied to classes, methods, getters, setters, fields, and auto-accessors (a new class member that is explained later). The values of .kind reflect that:

This is the exact type of Decorator:

type Decorator =
  | ClassDecorator
  | ClassMethodDecorator
  | ClassGetterDecorator
  | ClassSetterDecorator
  | ClassAutoAccessorDecorator
  | ClassFieldDecorator
;

We’ll soon encounter each of these kinds of decorators and its type signature – where only these parts change:

What can decorators do?

Each decorator has up to four abilities:

The next subsections demonstrate these abilities. We initially won’t use context.kind to check which kind of construct a decorator is applied to. We will do that later, though.

Ability: replacing the decorated entity

In the following example, the decorator @replaceMethod replaces method .hello() (line B) with a function that it returns (line A).

function replaceMethod() {
  return function () { // (A)
    return `How are you, ${this.name}?`;
  }
}

class Person {
  constructor(name) {
    this.name = name;
  }
  @replaceMethod
  hello() { // (B)
    return `Hi ${this.name}!`;
  }
}

const robin = new Person('Robin');
assert.equal(
  robin.hello(), 'How are you, Robin?'
);

Ability: exposing access to the decorated entity to others

In the next example, the decorator @exposeAccess stores an object in the variable acc that lets us access property .green of the instances of Color.

let acc;
function exposeAccess(_value, {access}) {
  acc = access;
}

class Color {
  @exposeAccess
  name = 'green'
}

const green = new Color();
assert.equal(
  green.name, 'green'
);
// Using `acc` to get and set `green.name`
assert.equal(
  acc.get.call(green), 'green'
);
acc.set.call(green, 'red');
assert.equal(
  green.name, 'red'
);

Ability: processing the decorated entity and its container

In the following code, we use the decorator @collect to store the keys of decorated methods in the instance property .collectedMethodKeys:

function collect(_value, {name, addInitializer}) {
  addInitializer(function () { // (A)
    if (!this.collectedMethodKeys) {
      this.collectedMethodKeys = new Set();
    }
    this.collectedMethodKeys.add(name);
  });
}

class C {
  @collect
  toString() {}
  @collect
  [Symbol.iterator]() {}
}
const inst = new C();
assert.deepEqual(
  inst.collectedMethodKeys,
  new Set(['toString', Symbol.iterator])
);

The initializer function added by the decorator in line A must be an ordinary function because access to the implicit parameter this is needed. Arrow functions don’t provide this access – their this is statically scoped (like any normal variable).

Summary tables

Type signature:

Kind of decorator (input) => output .access
Class (func) => func2
Method (func) => func2 {get}
Getter (func) => func2 {get}
Setter (func) => func2 {set}
Auto-accessor ({get,set}) => {get,set,init} {get,set}
Field () => (initValue)=>initValue2 {get,set}

Value of this in functions:

this is → undefined Class Instance
Decorator function
Static initializer
Non-static initializer
Static field decorator result
Non-static field decorator result

More information on the syntax and semantics of decorators (optional section)

(This section is optional. If you skip it, you can still understand the remaining content.)

The syntax of decorator expressions

@(«expr»)  

Wherever decorators are allowed, we can use more than one of them. The following code demonstrates decorator syntax:

// Five decorators for MyClass

@myFunc
@myFuncFactory('arg1', 'arg2')

@libraryModule.prop
@someObj.method(123)

@(wrap(dict['prop'])) // arbitrary expression

class MyClass {}

How are decorators executed?

The following code illustrates in which order decorator expressions, computed property keys and field initializers are evaluated:

function decorate(str) {
  console.log(`EVALUATE @decorate(): ${str}`);
  return () => console.log(`APPLY @decorate(): ${str}`); // (A)
}
function log(str) {
  console.log(str);
  return str;
}

@decorate('class')
class TheClass {

  @decorate('static field')
  static staticField = log('static field value');

  @decorate('prototype method')
  [log('computed key')]() {}

  @decorate('instance field')
  instanceField = log('instance field value');
    // This initializer only runs if we instantiate the class
}

// Output:
// EVALUATE @decorate(): class
// EVALUATE @decorate(): static field
// EVALUATE @decorate(): prototype method
// computed key
// EVALUATE @decorate(): instance field
// APPLY @decorate(): prototype method
// APPLY @decorate(): static field
// APPLY @decorate(): instance field
// APPLY @decorate(): class
// static field value

Function decorate is invoked whenever the expression decorate() after the @ symbol is evaluated. In line A, it returns the actual decorator function, which is applied later.

When do decorator initializers run?

When a decorator initializer runs, depends on the kind of decorator:

Why is that? For non-static initializers, we have five options – they can run:

  1. Before super
  2. After super, before field initialization
  3. Interleaved between fields in definition order
  4. After field initialization, before child class instantiation
  5. After child class instantiation

Why was #2 chosen?

The following code demonstrates in which order Babel currently invokes decorator initializers. Note that Babel does not yet support initializers for class field decorators (which was a recent change to the decorators API).

// We wait until after instantiation before we log steps,
// so that we can compare the value of `this` with the instance.
const steps = [];
function push(msg, _this) {
  steps.push({msg, _this});
}
function pushStr(str) {
  steps.push(str);
}

function init(_value, {name, addInitializer}) {
  pushStr(`@init ${name}`);
  if (addInitializer) {
    addInitializer(function () {
      push(`DECORATOR INITIALIZER ${name}`, this);
    });
  }
}

@init class TheClass {
  //--- Static ---

  static {
    pushStr('static block');
  }

  @init static staticMethod() {}
  @init static accessor staticAcc = pushStr('staticAcc');
  @init static staticField = pushStr('staticField');

  //--- Non-static ---

  @init prototypeMethod() {}
  @init accessor instanceAcc = pushStr('instanceAcc');
  @init instanceField = pushStr('instanceField');

  constructor() {
    pushStr('constructor');
  }
}

pushStr('===== Instantiation =====');
const inst = new TheClass();

for (const step of steps) {
  if (typeof step === 'string') {
    console.log(step);
    continue;
  }
  let thisDesc = '???';
  if (step._this === TheClass) {
    thisDesc = TheClass.name;
  } else if (step._this === inst) {
    thisDesc = 'inst';
  } else if (step._this === undefined) {
    thisDesc = 'undefined';
  }
  console.log(`${step.msg} (this===${thisDesc})`);
}

// Output:
// @init staticMethod
// @init staticAcc
// @init prototypeMethod
// @init instanceAcc
// @init staticField
// @init instanceField
// @init TheClass
// DECORATOR INITIALIZER staticMethod (this===TheClass)
// DECORATOR INITIALIZER staticAcc (this===TheClass)
// static block
// staticAcc
// staticField
// DECORATOR INITIALIZER TheClass (this===TheClass)
// ===== Instantiation =====
// DECORATOR INITIALIZER prototypeMethod (this===inst)
// DECORATOR INITIALIZER instanceAcc (this===inst)
// instanceAcc
// instanceField
// constructor

Techniques for exposing data from decorators

Sometimes decorators collect data. Let’s explore how they can make this data available to other parties.

Storing exposed data in a surrounding scope

The simplest solution is to store data in a location in a surrounding scope. For example, the decorator @collect collects classes and stores them in the Set classes (line A):

const classes = new Set(); // (A)

function collect(value, {kind, addInitializer}) {
  if (kind === 'class') {
    classes.add(value);
  }
}

@collect
class A {}
@collect
class B {}
@collect
class C {}

assert.deepEqual(
  classes, new Set([A, B, C])
);

The downside of this approach is that it doesn’t work if a decorator comes from another module.

Managing exposed data via a factory function

A more sophisticated approach is to use a factory function createClassCollector() that returns:

function createClassCollector() {
  const classes = new Set();
  function collect(value, {kind, addInitializer}) {
    if (kind === 'class') {
      classes.add(value);
    }
  }
  return {
    classes,
    collect,
  };
}

const {classes, collect} = createClassCollector();

@collect
class A {}
@collect
class B {}
@collect
class C {}

assert.deepEqual(
  classes, new Set([A, B, C])
);

Managing exposed data via a class

Instead of a factory function, we can also use a class. It has two members:

class ClassCollector {
  classes = new Set();
  install = (value, {kind}) => { // (A)
    if (kind === 'class') {
      this.classes.add(value); // (B)
    }
  };
}

const collector = new ClassCollector();

@collector.install
class A {}
@collector.install
class B {}
@collector.install
class C {}

assert.deepEqual(
  collector.classes, new Set([A, B, C])
);

We implemented .install by assigning an arrow function to a public instance field (line A). Instance field initializers run in scopes where this refers to the current instance. That is also the outer scope of the arrow function and explains what value this has in line B.

We could also implement .install via a getter, but then we’d have to return a new function whenever .install is read.

Class decorators

Class decorators have the following type signature:

type ClassDecorator = (
  value: Function,
  context: {
    kind: 'class';
    name: string | undefined;
    addInitializer(initializer: () => void): void;
  }
) => Function | void;

Abilities of a class decorator:

Example: collecting instances

In the next example, we use a decorator to collect all instances of a decorated class:

class InstanceCollector {
  instances = new Set();
  install = (value, {kind}) => {
    if (kind === 'class') {
      const _this = this;
      return function (...args) { // (A)
        const inst = new value(...args); // (B)
        _this.instances.add(inst);
        return inst;
      };
    }
  };
}

const collector = new InstanceCollector();

@collector.install
class MyClass {}

const inst1 = new MyClass();
const inst2 = new MyClass();
const inst3 = new MyClass();

assert.deepEqual(
  collector.instances, new Set([inst1, inst2, inst3])
);

The only way in which we can collect all instances of a given class via a decorator is by wrapping that class. The decorator in the field .install does that by returning a function (line A) that new-calls the decorated value (line B) and collects and returns the result.

Note that we can’t return an arrow function in line A, because arrow functions can’t be new-called.

One downside of this approach is that it breaks instanceof:

assert.equal(
  inst1 instanceof MyClass,
  false
);

The next subsection explains how we can fix that.

Making sure that instanceof works

In this section, we use the simple decorator @countInstances to show how we can support instanceof for wrapped classes.

Enabling instanceof via .prototype

One way of enabling instanceof is to set the .prototype of the wrapper function to the .prototype of the wrapped value (line A):

function countInstances(value) {
  const _this = this;
  let instanceCount = 0;
  // The wrapper must be new-callable
  const wrapper = function (...args) {
    instanceCount++;
    const instance = new value(...args);
    // Change the instance
    instance.count = instanceCount;
    return instance;
  };
  wrapper.prototype = value.prototype; // (A)
  return wrapper;
}

@countInstances
class MyClass {}

const inst1 = new MyClass();
assert.ok(inst1 instanceof MyClass);
assert.equal(inst1.count, 1);

const inst2 = new MyClass();
assert.ok(inst2 instanceof MyClass);
assert.equal(inst2.count, 2);

Why does that work? Because the following expressions are equivalent:

inst instanceof C
C.prototype.isPrototypeOf(inst)

For more information on instanceof, see “Exploring JavaScript”.

Enabling instanceof via Symbol.hasInstance

Another option for enabling instanceof is to give the wrapper function a method whose key is Symbol.hasInstance (line A):

function countInstances(value) {
  const _this = this;
  let instanceCount = 0;
  // The wrapper must be new-callable
  const wrapper = function (...args) {
    instanceCount++;
    const instance = new value(...args);
    // Change the instance
    instance.count = instanceCount;
    return instance;
  };
  // Property is read-only, so we can’t use assignment
  Object.defineProperty( // (A)
    wrapper, Symbol.hasInstance,
    {
      value: function (x) {
        return x instanceof value; 
      }
    }
  );
  return wrapper;
}

@countInstances
class MyClass {}

const inst1 = new MyClass();
assert.ok(inst1 instanceof MyClass);
assert.equal(inst1.count, 1);

const inst2 = new MyClass();
assert.ok(inst2 instanceof MyClass);
assert.equal(inst2.count, 2);

“Exploring JavaScript” has more information on Symbol.hasInstance.

Enabling instanceof via subclassing

We can also enable instanceof by returning a subclass of value (line A):

function countInstances(value) {
  const _this = this;
  let instanceCount = 0;
  // The wrapper must be new-callable
  return class extends value { // (A)
    constructor(...args) {
      super(...args);
      instanceCount++;
      // Change the instance
      this.count = instanceCount;
    }
  };
}

@countInstances
class MyClass {}

const inst1 = new MyClass();
assert.ok(inst1 instanceof MyClass);
assert.equal(inst1.count, 1);

const inst2 = new MyClass();
assert.ok(inst2 instanceof MyClass);
assert.equal(inst2.count, 2);

Example: freezing instances

The decorator class @freeze freezes all instances produced by the classes it decorates:

function freeze (value, {kind}) {
  if (kind === 'class') {
    return function (...args) {
      const inst = new value(...args);
      return Object.freeze(inst);
    }
  }
}

@freeze
class Color {
  constructor(name) {
    this.name = name;
  }
}

const red = new Color('red');
assert.throws(
  () => red.name = 'green',
  /^TypeError: Cannot assign to read only property 'name'/
);

This decorator has downsides:

The last downside could be avoided by giving class decorators access to the instances of the decorated classes after all constructors were executed.

This would change how inheritance works because a superclass could now change properties that were added by subclasses. Therefore, it’s not sure if such a mechanism is in the cards.

Example: making classes function-callable

Classes decorated by @functionCallable can be invoked by function calls instead of the new operator:

function functionCallable(value, {kind}) {
  if (kind === 'class') {
    return function (...args) {
      if (new.target !== undefined) {
        throw new TypeError('This function can’t be new-invoked');
      }
      return new value(...args);
    }
  }
}

@functionCallable
class Person {
  constructor(name) {
    this.name = name;
  }
}
const robin = Person('Robin');
assert.equal(
  robin.name, 'Robin'
);

Class method decorators

Class method decorators have the following type signature:

type ClassMethodDecorator = (
  value: Function,
  context: {
    kind: 'method';
    name: string | symbol;
    static: boolean;
    private: boolean;
    access: { get: () => unknown };
    addInitializer(initializer: () => void): void;
  }
) => Function | void;

Abilities of a method decorator:

Constructors can’t be decorated: They look like methods, but they aren’t really methods.

Example: tracing method invocations

The decorator @trace wraps methods so that their invocations and results are logged to the console:

function trace(value, {kind, name}) {
  if (kind === 'method') {
    return function (...args) {
      console.log(`CALL <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mrow><mi>n</mi><mi>a</mi><mi>m</mi><mi>e</mi></mrow><mo>:</mo></mrow><annotation encoding="application/x-tex">{name}: </annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord"><span class="mord mathnormal">nam</span><span class="mord mathnormal">e</span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span></span></span></span>{JSON.stringify(args)}`);
      const result = value.apply(this, args);
      console.log('=> ' + JSON.stringify(result));
      return result;
    };
  }
}

class StringBuilder {
  #str = '';
  @trace
  add(str) {
    this.#str += str;
  }
  @trace
  toString() {
    return this.#str;
  }
}

const sb = new StringBuilder();
sb.add('Home');
sb.add('page');
assert.equal(
  sb.toString(), 'Homepage'
);

// Output:
// CALL add: ["Home"]
// => undefined
// CALL add: ["page"]
// => undefined
// CALL toString: []
// => "Homepage"

Example: binding methods to instances

Normally, extracting methods (line A) means that we can’t function-call them because that sets this to undefined:

class Color1 {
  #name;
  constructor(name) {
    this.#name = name;
  }
  toString() {
    return `Color(${this.#name})`;
  }
}

const green1 = new Color1('green');
const toString1 = green1.toString; // (A)
assert.throws(
  () => toString1(),
  /^TypeError: Cannot read properties of undefined/
);

We can fix that via the decorator @bind:

function bind(value, {kind, name, addInitializer}) {
  if (kind === 'method') {
    addInitializer(function () { // (B)
      this[name] = value.bind(this); // (C)
    });
  }
}

class Color2 {
  #name;
  constructor(name) {
    this.#name = name;
  }
  @bind
  toString() {
    return `Color(${this.#name})`;
  }
}

const green2 = new Color2('green');
const toString2 = green2.toString;
assert.equal(
  toString2(), 'Color(green)'
);

// The own property green2.toString is different
// from Color2.prototype.toString
assert.ok(Object.hasOwn(green2, 'toString'));
assert.notEqual(
  green2.toString,
  Color2.prototype.toString
);

Per decorated method, the initializer registered in line B is invoked whenever an instance is created and adds an own property whose value is a function with a fixed this (line C).

Example: applying functions to methods

The library core-decorators has a decorator that lets us apply functions to methods. That enables us to use helper functions such as Lodash’s memoize(). The following code shows an implementation @applyFunction of such a decorator:

import { memoize } from 'lodash-es';

function applyFunction(functionFactory) {
  return (value, {kind}) => { // decorator function
    if (kind === 'method') {
      return functionFactory(value);
    }
  };
}

let invocationCount = 0;

class Task {
  @applyFunction(memoize)
  expensiveOperation(str) {
    invocationCount++;
    // Expensive processing of `str` 😀
    return str + str;
  }
}

const task = new Task();
assert.equal(
  task.expensiveOperation('abc'),
  'abcabc'
);
assert.equal(
  task.expensiveOperation('abc'),
  'abcabc'
);
assert.equal(
  invocationCount, 1
);

Class getter decorators, class setter decorators

These are the type signatures of getter decorators and setter decorators:

type ClassGetterDecorator = (
  value: Function,
  context: {
    kind: 'getter';
    name: string | symbol;
    static: boolean;
    private: boolean;
    access: { get: () => unknown };
    addInitializer(initializer: () => void): void;
  }
) => Function | void;

type ClassSetterDecorator = (
  value: Function,
  context: {
    kind: 'setter';
    name: string | symbol;
    static: boolean;
    private: boolean;
    access: { set: (value: unknown) => void };
    addInitializer(initializer: () => void): void;
  }
) => Function | void;

Getter decorators and setter decorators have similar abilities to method decorators.

Example: computing values lazily

To implement a property whose value is computed lazily (on demand), we use two techniques:

class C {
  @lazy
  get value() {
    console.log('COMPUTING');
    return 'Result of computation';
  }
}

function lazy(value, {kind, name, addInitializer}) {
  if (kind === 'getter') {
    return function () {
      const result = value.call(this);
      Object.defineProperty( // (A)
        this, name,
        {
          value: result,
          writable: false,
        }
      );
      return result;
    };
  }
}

console.log('1 new C()');
const inst = new C();
console.log('2 inst.value');
assert.equal(inst.value, 'Result of computation');
console.log('3 inst.value');
assert.equal(inst.value, 'Result of computation');
console.log('4 end');

// Output:
// 1 new C()
// 2 inst.value
// COMPUTING
// 3 inst.value
// 4 end

Note that property .[name] is immutable (because there is only a getter), which is why we have to define the property (line A) and can’t use assignment.

Class field decorators

Class field decorators have the following type signature:

type ClassFieldDecorator = (
  value: undefined,
  context: {
    kind: 'field';
    name: string | symbol;
    static: boolean;
    private: boolean;
    access: { get: () => unknown, set: (value: unknown) => void };
    addInitializer(initializer: () => void): void;
  }
) => (initialValue: unknown) => unknown | void;

Abilities of a field decorator:

Example: changing initialization values of fields

The decorator @twice doubles the original initialization value of a field by returning a function that performs this change:

function twice() {
  return initialValue => initialValue * 2;
}

class C {
  @twice
  field = 3;
}

const inst = new C();
assert.equal(
  inst.field, 6
);

Example: read-only fields (instance public fields)

The decorator @readOnly makes a field immutable. It waits until the field was completely set up (either via an assignment or via the constructor) before it does so.

const readOnlyFieldKeys = Symbol('readOnlyFieldKeys');

@readOnly
class Color {
  @readOnly
  name;
  constructor(name) {
    this.name = name;
  }
}

const blue = new Color('blue');
assert.equal(blue.name, 'blue');
assert.throws(
  () => blue.name = 'brown',
  /^TypeError: Cannot assign to read only property 'name'/
);

function readOnly(value, {kind, name}) {
  if (kind === 'field') { // (A)
    return function () {
      if (!this[readOnlyFieldKeys]) {
        this[readOnlyFieldKeys] = [];
      }
      this[readOnlyFieldKeys].push(name);
    };
  }
  if (kind === 'class') { // (B)
    return function (...args) {
      const inst = new value(...args);
      for (const key of inst[readOnlyFieldKeys]) {
        Object.defineProperty(inst, key, {writable: false});
      }
      return inst;
    }
  }
}

We need two steps to implement the functionality of @readOnly (which is why the class is also decorated):

Similarly to making instances immutable, this decorator breaks instanceof. The same workaround can be used here, too.

We’ll later see a version @readOnly that works with auto-accessors instead of fields. That implementation does not require the class to be decorated.

Example: dependency injection (instance public fields)

Dependency injection is motivated by the following observation: If we provide the constructor of a class with its dependencies (vs. the constructor setting them up itself), then it’s easier to adapt the dependencies to different environments, including testing.

This is an inversion of control: The constructor does not do its own setup, we do it for it. Approaches for doing dependency injection:

  1. Manually, by creating dependencies and passing them to the constructor.
  2. Via “contexts” in frontend frameworks such as React
  3. Via decorators and a dependency injection registry (a minor variation of dependency injection containers)

The following code is a simple implementation of approach #3:

const {registry, inject} = createRegistry();

class Logger {
  log(str) {
    console.log(str);
  }
}
class Main {
  @inject logger;
  run() {
    this.logger.log('Hello!');
  }
}

registry.register('logger', Logger);
new Main().run();

// Output:
// Hello!

This is how createRegistry() is implemented:

function createRegistry() {
  const nameToClass = new Map();
  const nameToInstance = new Map();
  const registry = {
    register(name, componentClass) {
      nameToClass.set(name, componentClass);
    },
    getInstance(name) {
      if (nameToInstance.has(name)) {
        return nameToInstance.get(name);
      }
      const componentClass = nameToClass.get(name);
      if (componentClass === undefined) {
        throw new Error('Unknown component name: ' + name);
      }
      const inst = new componentClass();
      nameToInstance.set(name, inst);
      return inst;
    },
  }; 
  function inject (_value, {kind, name}) {
    if (kind === 'field') {
      return () => registry.getInstance(name);
    }
  }
  return {registry, inject};
}

Example: “friend” visibility (instance private fields)

We can change the visibility of some class members by making them private. That prevents them from being accessed publicly. There are more useful kinds of visibility, though. For example, friend visibility lets a group of friends (functions, other classes, etc.) access the member.

There are many ways in which friends can be specified. In the following example, everyone who has access to friendName, is a friend of classWithSecret.#name. The idea is that a module contains classes and functions that collaborate and that there is some instance data that only the collaborators should be able see.

const friendName = new Friend();

class ClassWithSecret {
  @friendName.install #name = 'Rumpelstiltskin';
  getName() {
    return this.#name;
  }
}

// Everyone who has access to `secret`, can access inst.#name
const inst = new ClassWithSecret();
assert.equal(
  friendName.get(inst), 'Rumpelstiltskin'
);
friendName.set(inst, 'Joe');
assert.equal(
  inst.getName(), 'Joe'
);

This is how class Friend is implemented:

class Friend {
  #access = undefined;
  #getAccessOrThrow() {
    if (this.#access === undefined) {
      throw new Error('The friend decorator wasn’t used yet');
    }
    return this.#access;
  }
  // An instance property whose value is a function whose `this`
  // is fixed (bound to the instance).
  install = (_value, {kind, access}) => {
    if (kind === 'field') {
      if (this.#access) {
        throw new Error('This decorator can only be used once');
      }
      this.#access = access;
    }
  }
  get(inst) {
    return this.#getAccessOrThrow().get.call(inst);
  }
  set(inst, value) {
    return this.#getAccessOrThrow().set.call(inst, value);
  }
}

Example: enums (static public fields)

There are many ways to implement enums. An OOP-style approach is to use a class and static properties (more information on this approach):

class Color {
  static red = new Color('red');
  static green = new Color('green');
  static blue = new Color('blue');
  constructor(enumKey) {
    this.enumKey = enumKey;
  }
  toString() {
    return `Color(${this.enumKey})`;
  }
}
assert.equal(
  Color.green.toString(),
  'Color(green)'
);

We can use a decorator to automatically:

That looks as follows:

function enumEntry(value, {kind, name}) {
  if (kind === 'field') {
    return function (initialValue) {
      if (!Object.hasOwn(this, 'enumFields')) {
        this.enumFields = new Map();
      }
      this.enumFields.set(name, initialValue);
      initialValue.enumKey = name;
      return initialValue;
    };
  }
}

class Color {
  @enumEntry static red = new Color();
  @enumEntry static green = new Color();
  @enumEntry static blue = new Color();
  toString() {
    return `Color(${this.enumKey})`;
  }
}
assert.equal(
  Color.green.toString(),
  'Color(green)'
);
assert.deepEqual(
  Color.enumFields,
  new Map([
    ['red', Color.red],
    ['green', Color.green],
    ['blue', Color.blue],
  ])
);

Auto-accessors: a new member of class definitions

The decorators proposal introduces a new language feature: auto-accessors. An auto-accessor is created by putting the keyword accessor before a class field. It is used like a field but implemented differently at runtime. That helps decorators as we’ll see soon. This is what auto-accessors look like:

class C {
  static accessor myField1;
  static accessor #myField2;
  accessor myField3;
  accessor #myField4;
}

How do fields and auto-accessors differ?

Consider the following class:

class C {
  accessor str = 'abc';
}
const inst = new C();
assert.equal(
  inst.str, 'abc'
);
inst.str = 'def';
assert.equal(
  inst.str, 'def'
);

Internally, it looks like this:

class C {
  #str = 'abc';
  get str() {
    return this.#str;
  }
  set str(value) {
    this.#str = value;
  }
}

The following code shows where the getters and setters of auto-accessors are located:

class C {
  static accessor myField1;
  static accessor #myField2;
  accessor myField3;
  accessor #myField4;

  static {
    // Static getter and setter
    assert.ok(
      Object.hasOwn(C, 'myField1'), 'myField1'
    );
    // Static getter and setter
    assert.ok(
      #myField2 in C, '#myField2'
    );

    // Prototype getter and setter
    assert.ok(
      Object.hasOwn(C.prototype, 'myField3'), 'myField3'
    );
    // Private getter and setter
    // (stored in instances, but shared between instances)
    assert.ok(
      #myField4 in new C(), '#myField4'
    );
  }
}

For more information on why the slots of private getters, private setters and private methods are stored in instances, see section “Private methods and accessors” in “Exploring JavaScript”.

Why are auto-accessors needed?

Auto-accessors are needed by decorators:

Therefore, we have to use auto-accessors instead of fields whenever a decorator needs more control than it has with fields.

Class auto-accessor decorators

Class auto-accessor decorators have the following type signature:

type ClassAutoAccessorDecorator = (
  value: {
    get: () => unknown;
    set: (value: unknown) => void;
  },
  context: {
    kind: 'accessor';
    name: string | symbol;
    static: boolean;
    private: boolean;
    access: { get: () => unknown, set: (value: unknown) => void };
    addInitializer(initializer: () => void): void;
  }
) => {
  get?: () => unknown;
  set?: (value: unknown) => void;
  init?: (initialValue: unknown) => unknown;
} | void;

Abilities of an auto-accessor decorator:

Example: read-only auto-accessors

We have already implemented a decorator @readOnly for fields. Let’s do the same for auto-accessors:

const UNINITIALIZED = Symbol('UNINITIALIZED');
function readOnly({get,set}, {name, kind}) {
  if (kind === 'accessor') {
    return {
      init() {
        return UNINITIALIZED;
      },
      get() {
        const value = get.call(this);
        if (value === UNINITIALIZED) {
          throw new TypeError(
            `Accessor ${name} hasn’t been initialized yet`
          );
        }
        return value;
      },
      set(newValue) {
        const oldValue = get.call(this);
        if (oldValue !== UNINITIALIZED) {
          throw new TypeError(
            `Accessor ${name} can only be set once`
          );
        }
        set.call(this, newValue);
      },
    };
  }
}

class Color {
  @readOnly
  accessor name;
  constructor(name) {
    this.name = name;
  }
}

const blue = new Color('blue');
assert.equal(blue.name, 'blue');
assert.throws(
  () => blue.name = 'yellow',
  /^TypeError: Accessor name can only be set once$/
);

const orange = new Color('orange');
assert.equal(orange.name, 'orange');

Compared to the field version, this decorator has one considerable advantage: It does not need to wrap the class to ensure that the decorated constructs become read-only.

Frequently asked questions

Why can’t functions be decorated?

The current proposal focuses on classes as a starting point. Decorators for function expressions were proposed. However, there hasn’t been much progress since then and there is no proposal for function declarations.

On the other hand, functions are relatively easy to decorate “manually”:

const decoratedFunc = decorator((x, y) => {});

This looks even better with the proposed pipeline operator:

const decoratedFunc = (x, y) => {} |> decorator(%);

The following ECMAScript proposals provide more decorator-related features:

Resources

Implementations

Libraries with decorators

These are libraries with decorators. They currently only support stage 1 decorators but can serve as inspirations for what’s possible:

Acknowledgements

Further reading