JavaScript decorators are finally in Stage 3


On April 18, 2022, after 5 years of development (first commit on April 30, 2017), the decorator proposal finally reached stage 3, which means it has a spec, test implementation, and only polish based on developer feedback. Considering that it is already fourth (!) decorators iteration, their transition to the adoption stage is a landmark event for JS – I don’t remember any other feature that has gone such a long and thorny path, with diametrically different approaches and even two different legacy implementations, in Babel and TypeScript. Let’s take a closer look at her.

Links

https://github.com/tc39/proposal-decorators – the repository of the proposal itself, including all previous versions (in the commit history).

http://senocular.github.io/articles/js_history_of_decorators.html – a history of proposals, including links to all four major versions.

https://javascriptdecorators.org/ – independent implementation

https://babeljs.io/docs/en/babel-plugin-proposal-decorators – Plugin for Babel

By the way, the new version is dated in Babel as 2021-12 – because it was presented at the TC39 summit in December 2021.

How is it different from previous versions

First, the new decorators are still working only with classes and their members. However, suggestions to extend the same logic there are functions/parameters/objects/variables/annotations/blocks/initializers, but they are not included in the current spec (which is not surprising, hardly anyone wants to spend another 5 years reaching Stage 4).

Secondly, the main difference between the new decorators: they work only with the entity they are decorating (class, class field, method, getter/setter, etc.). accessor – a new entity, which will be discussed later), and not with property descriptors and / or class prototypes, as legacy approaches.

That is, they are not able to add new entities to the prototype / instance of the class or at least change their appearance (from a field to a getter / setter, for example), but can only transform the entity that is described in the source code – wrap it in additional logic or completely replace with another, but of the same type.

This was done primarily under pressure from the developers V8 core engines, as the excessive flexibility of previous decorators was extremely ill-suited for code optimization at runtime – which is why the adoption of decorators took so long.

Demo and syntax for applying decorators

Well, immediately a complete example with all possible combinations of syntax:

//export должен быть перед декоратором
export default
//декоратор класса, может изменять сам класс
@defineElement("some-element")
class SomeElement extends HTMLElement {
  //декоратор поля - может заменить значение поля при инициализации класса
  //все дальнейшие чтения/записи он не отслеживает
  @inject('some-dep')
  dep

  //новый синтаксис - аксессор
  //по факту просто сахар для пары геттер/сеттер
  //похож на автоматически реализуемые свойства в C#
  //могут быть и приватными и статическими
  //декоратор может отслеживать чтение/запись
  @reactive accessor clicked = false

  //ну с методами и прочим все как обычно
  @logged
  someMethod() {
    return 42
  }

  //да, с приватными элементами тоже работает, как и со статическими
  //название декоратора может быть через точку
  @random.int(0, 42)
  #val

  @logged
  get val() {
    return this.#val
  }

  @logged
  set val(value) {
    this.#val = value
  }

  //апофеоз:
  //статический приватный аксессор c декоратором со сложным доступом
  @(someArr[3].someFunc('param'))
  static acсessor #name="some-element"
}

The current implementations of polyfills cannot fully digest this example yet, but I think this will be fixed soon.

The syntax for applying decorators is generally not too different from the usual, there are only a couple of details:

  1. The class decorator must go after export (if any) is probably the main difference from the status quo.

  2. For a “normal” decorator application, one can use an identifier, a dot, and a function call – @dotted.form.with('some-call')

  3. For “complex” applications, you can use the syntax with brackets: @(complex[1])

Writing Decorators

No big surprises here – a decorator is just a simple function with a type like this:

context provides, oddly enough, context, information about where the decorator is applied, where:

  • kind – the type of element on which the decorator is applied

  • name – element name

  • access – an object that allows you to get / set the value of an element at an arbitrary point in time, can be useful, for example, for DI. Only allowed on class members, not classes themselves (i.e. get or set there is only when kind != 'class')

  • private and static – whether the class element has appropriate modifiers

  • addInitializer allows you to execute code after the class itself (not an instance!) or a class element is fully defined – for example, you can register a class in DI or bind a method in it. Not applicable only for a class field (i.e. defined when kind != 'field' – more on that later)

Input and Output depends on kindbut in general Input is the value of the element as it is written in the code, and Output – the value to which it will be replaced in runtime.

An important nuance is for class fields (when kind == 'field') Input always undefineda Output can be a function of the form (initValue: unknown) => any – this function is called when the class is initialized to calculate the initial value of the field. It is because of this that the class field is not passed addInitializerOutput replaces it.

Decorator Example logged:

function logged(value, { kind, name }) {
  if (kind === "method") {
    return function (...args) {
      console.log(`starting ${name} with arguments ${args.join(", ")}`);
      const ret = value.call(this, ...args);
      console.log(`ending ${name}`);
      return ret;
    };
  }
  if (kind === "field") {
    return function (initialValue) {
      console.log(`initializing ${name} with value ${initialValue}`);
      return initialValue;
    };
  }
  if (kind === "class") {
    return class extends value {
      constructor(...args) {
        super(...args);
        console.log(`constructing an instance of ${name} with arguments ${args.join(", ")}`);
      }
    }
}

Or here customElement using addInitializer:

function customElement(name) {
  (value, { addInitializer }) => {
    addInitializer(function() {
      customElements.define(name, this);
    });
  }
}

@customElement('my-element')
class MyElement extends HTMLElement {
  static get observedAttributes() {
    return ['some', 'attrs'];
  }
}

More examples (including those using access for DI) see on github.

Accessors

The restrictions of the new decorators in the form of a ban on changing the appearance of an element are generally logical, but they kill one extremely important use case of decorators – when the field turns into a getter / setter pair with additional logic around. This can be, for example, logging field changes for debugging, or it can be a full-fledged reactivity system, as in MobX, which, in fact, is based on this hack:

import {computed, observable, autorun} from 'mobx'

class Counter {
  	//вот здесь поле превращается в геттер/сеттер
    @observable num = 1
  	//а будет так
    @observable accessor num = 1
    
    @computed
    get double() {
        return this.num * 2
    }
}

const counter = new Counter()

//выведет 2
autorun(() => console.log(counter.double)) 

//когда изменяем num, изменится и double
counter.num = 2
//autorun выполняется снова и выводит 4

With the new decorators, all such fields will have to be marked as accessor which, of course, is not too fun, but in general it is tolerable and can be tracked, for example, with a typescript. Under the hood it will work like this:

class C {
  accessor x = 1;
}

//Раскрывается в...

class C {
  #x = 1;

  get x() {
    return this.#x;
  }

  set x(val) {
    this.#x = val;
  }
}

Implementations

While we are waiting for the implementation in the main tools – first of all, of course, this is support for accessors as a new syntax. Once the IDE, TypeScript, and Babel (esbuild, etc.) can handle them correctly, making polyfills won’t be that hard.

And I really hope that TypeScript will correctly handle the types of decorators when replacing values ​​- now the decorator cannot affect the type of the value being decorated in any way.

Implementation Tracking Links:

https://github.com/microsoft/TypeScript/issues/48885 – TypeScript – feature is included in the plans for version 4.8.

https://github.com/evanw/esbuild/issues/104 – esbuild – waiting for implementation in TS/node/browsers.

Well, then a wave of moving to a new implementation from the side of the ecosystem will follow. Luckily, decorators in JS are not that common, and new decorators can be implemented in libraries. along with the old – their signature is different from Babel/TS decorators.

We waited, in general.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *