JavaScript metaprogramming with the 2022-03 decorators API

[2022-10-18] dev, javascript, es proposal
(Ad, please don’t block)

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:

  • Library authors have to know their API so that they can implement them.
  • Library users only have to know how to apply them.

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:

  • Why is this proposal taking so long?
  • Why does it feel like JavaScript has already had decorators for years?

The history of decorators  

The following history describes:

  • How various groups both worked on their own projects and collaborated on the TC39 proposal.
  • How the TC39 proposal advanced through the stages of the TC39 process (which start at 0 and end at 4, when the proposal is ready to be added to ECMAScript). Along the way, the proposal changed in numerous ways.

This is a chronological account of relevant events:

  • 2014-04-10: Decorators were proposed to TC39 by Yehuda Katz. The proposal advanced to stage 0.

    • Katz’s proposal was created in collaboration with Ron Buckton. Discussions about that proposal date back as far as July 2013.
  • 2014-10-22 (ngEurope conference, Paris): The Angular team announced that Angular 2.0 was being written in AtScript and compiled to JavaScript (via Traceur) and Dart. Plans included basing AtScript on TypeScript while adding:

    • Three kinds of annotations:
      • Type annotations
      • Field annotations explicitly declare fields.
      • Metadata annotations have the same syntax as decorators but only add metadata and don’t change how annotated constructs work.
    • Runtime type checking
    • Type introspection
  • 2015-01-28: Yehuda Katz and Jonathan Turner reported that Katz and the TypeScript team were exchanging ideas.

  • 2015-03-05 (ng-conf, Salt Lake City): The Angular team and the TypeScript team announced that Angular would switch from AtScript to TypeScript and that TypeScript would adopt some of AtScript’s features (especially decorators).

  • 2015-03-24: The decorator proposal reached stage 1. At that time, they had a repository on GitHub (created by Yehuda Katz) that was later moved to its current location.

  • 2015-07-20: TypeScript 1.5 came out and supported stage 1 decorators behind the flag --experimentalDecorators.

    Several JavaScript projects (e.g. Angular and MobX) used this TypeScript feature which made it look like JavaScript already had decorators.

    So far, TypeScript has not supported a newer version of the decorators API. A pull request by Ron Buckton provides support for stage 3 decorators and will likely ship in the release after v4.9.

  • 2016-07-28: The proposal reached stage 2, after a presentation by Yehuda Katz and Brian Terlson.

  • 2017-07-27: Daniel Ehrenberg held his first decorator presentation, after joining the proposal a few months earlier. He drove its evolution for several years.

  • Later, Chris Garrett joined the proposal and helped get it to stage 3, which happened on 2022-03-28. Decorator metadata was moved to a separate proposal that started at stage 2.

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:

  • We don’t write code that processes user data (programming).
  • We write code that processes code that processes user data (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:

  • The value that the decorator is applied to.
  • The object context with:
    • Additional information on value (.static, .private)
    • A small API (.access, .addInitializer) with metaprogramming functionality

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:

  • 'class'
  • 'method'
  • 'getter'
  • 'setter'
  • 'accessor'
  • 'field'

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:

  • The type of value
  • Some of the properties of context
  • The return type

What can decorators do?  

Each decorator has up to four abilities:

  • It can change the decorated entity by changing the parameter value.

  • It can replace the decorated entity by returning a compatible value:

    • “Compatible” means that the returned value must have the same type as the decorated value – e.g., class decorators must return callable values.
    • If the decorator doesn’t want to replace the decorated value, it can return undefined – either explicitly or implicitly, by not returning anything.
  • Exposing access to the decorated entity to others. context.access enables it to do that, via its methods .get() and .set().

  • Processing the decorated entity and its container (if it has one), after both exist: That functionality is provided by context.addInitializer. It lets the decorator register an initializer – a callback that is invoked when everything is ready (more details are explained later).

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  

  • A decorator expression starts with a chain of one or more identifiers, separated by dots. Each identifier except the first one can be private (prefix #). Square brackets [] are not allowed.
  • Optional at the end: function call arguments in parentheses. The next subsection explains what that means.
  • We can use any expression if we put it in parentheses:
    @(«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?  

  • Evaluation: The expressions after the @ symbols are evaluated during the execution of the class definition, along with computed property keys and static fields (see code below). The results must be functions. They are stored in temporary locations (think local variables), to be invoked later.

  • Invocation: The decorator functions are called later during the execution of a class definition, after methods have been evaluated but before constructor and prototype have been assembled. Once again the results are stored in temporary locations.

  • Application: After all decorator functions were invoked, their results are used, which can affect constructor and prototype. Class decorators are applied after all method and field decorators.

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:

  • Class decorator initializers run after the class is fully defined and all static fields were initialized.

  • The initializers of non-static class element decorators run during instantiation, before instance fields are initialized.

  • The initializers of static class element decorators run during class definition, before static fields are defined but after other all other class elements were defined.

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?

  • #1 was rejected because decorator initializers must be able to access this, which isn’t possible before super runs.

  • #3 was rejected because running all decorator initializers at the same time is simpler than ensuring that they are properly interleaved.

  • #4 was rejected because running decorator initializers before fields ensures that fields don’t see partially initialized methods. For example, if there are @bind decorators, then field initializers can rely on the decorated methods being bound.

  • #5 was rejected because it would allow superclasses to interfere with subclasses, which would break the rule that superclasses should not be aware of their subclasses.

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:

  • A class decorator collect
  • A Set classes, to which the decorator will add the classes it collects
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:

  • .classes, a Set with the collected classes
  • .install, a class decorator
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:

  • It can change the decorated class by changing value.
  • It can replace the decorated class by returning a callable value.
  • It can register initializers, which are called after the decorated class is fully set up.
  • It does not get context.access because classes are not members of other language constructs (whereas, e.g., methods are members of classes).

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 “JavaScript for impatient programmers”.

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 ready-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);

“JavaScript for impatient programmers” 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:

  • It breaks instanceof. We have already seen how to fix this.
  • Subclassing a decorated class doesn’t work well:
    • The way in which constructors are connected isn’t ideal – with a wrapped constructor in the mix. This can be partially fixed by returning a subclass of the decorated value.
    • Subclasses can’t set up properties, because their this is immutable. There is no way to avoid this downside.

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:

  • It can change the decorated method by changing value.
  • It can replace the decorated method by returning a function.
  • It can register initializers.
  • context.access only supports getting the value of its property, not setting it.

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 ${name}: ${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:

  • We implement the property via a getter. That way, the code that computes its value, is only executed if the property is read.

  • The decorator @lazy wraps the original getter: When the wrapper is invoked for the first time, it invokes the getter and creates an own data property whose value is the result. From now on, the own property overrides the inherited getter whenever someone reads the property.

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:

  • It cannot change or replace its field. If we need that functionality, we have to use an auto-accessor (what that is, is described later).

  • It can change the value with which “its” field is initialized, by returning a function that receives the original initialization value and returns a new initialization value.

    • Inside that function, this refers to the current instance.
  • It can register initializers. That is a recent change (post-2022-03) of the decorators API and wasn’t possible before.

  • It can expose access to its field (even if it’s private) via context.access.

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):

  • We first collect all keys of read-only fields (line A).
  • Then we wait until the instance was completely set up and make the fields, whose keys we collected, non-writable (line B). We need to wrap the class because decorator initializers are executed too early.

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:

  • Create a Map from “enum keys” (the names of their fields) to enum values.
  • Add enum keys to enum values – without having to pass them to the constructor.

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?

  • A field creates either:
    • Properties (static or instance)
    • Private slots (static or instance)
  • An auto-accessor creates a private slot (static or instance) for the data and:
    • A public getter-setter pair (static or prototype)
    • A private getter-setter pair (static or instance)
      • Private slots are not inherited and therefore never located in prototypes.

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 “JavaScript for impatient programmers”.

Why are auto-accessors needed?  

Auto-accessors are needed by decorators:

  • They can only influence the values fields are initialized with.
  • But they can completely replace auto-accessors.

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:

  • It receives the getter and the setter of the auto-accessor via its parameter value.
    • context.access provides the same functionality.
  • It can replace the decorated auto-accessor by returning an object with the methods .get() and/or .set().
  • It can influence the initial value of the auto-accessor by returning an object with the method .init().
  • It can register initializers.

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  

  • Thanks to Chris Garrett for answering my questions about decorators.

Further reading