The future of JavaScript: decorators





Good day, friends!



I present to your attention an adapted translation of the new proposal (September 2020) regarding the use of decorators in JavaScript, with a little explanation as to what is happening.



This proposal was first made about 5 years ago and since then has undergone several significant changes. It is currently (still) in the second stage of consideration.



If you haven't heard of decorators before or want to brush up on your knowledge, I recommend that you read the following articles:





So what is a decorator? A decorator is a function called on an element of a class (field or method) or on the class itself during its definition, which wraps or replaces the element (or class) with a new value (returned by the decorator).



A decorated class field is treated as a wrapper from a getter / setter, allowing you to retrieve / assign (change) a value to that field.



Decorators can also annotate a class member with metadata. Metadata is a collection of simple object properties added by decorators. They are available as a set of nested objects in the [Symbol.metadata] property.



Syntax



Decorator syntax, in addition to the @ (@decoratorName) prefix, assumes the following:



  • Decorator expressions are limited to variable chaining (multiple decorators can be used), accessing the property with., But not with [], and calling with ()
  • Not only class definitions can be decorated, but also their elements (fields and methods)
  • Class decorators are specified after export and default


There are no special rules for defining decorators; any function can be used as such.



Semantics details



The decorator is evaluated in three steps:



  1. Decorator expression (anything after @) is evaluated along with computed property names
  2. The decorator is called (as a function) during the class definition, after the methods are evaluated, but before the constructor and prototype are combined
  3. Decorator is applied (changes constructor and prototype) only once after call


1. Computing decorators



Decorators are evaluated as expressions along with computed property names. This happens from left to right and top to bottom. The result of the decorator is stored in a kind of local variable that is called (used) after the class definition is complete.



2. Calling decorators



The decorator is called with two arguments: the wrapped element and, optionally, the context object.



Wrapped element: first parameter



The first argument that the decorator wraps around is what we decorate (sorry for the tautology):



  • When it comes to a simple method, initialization method, getter or setter: the corresponding function
  • If about the class: the class itself
  • If about field: an object with two properties:



    • get: a parameterless function that is called with a receiver, which is an object that returns the value it contains
    • set: a function that takes one parameter (new value), which is called with a receiver that is the passed object, and returns undefined


Context object: second parameter



The context object - the object passed to the decorator as the second argument - contains the following properties:



  • kind: has one of the following values:



    • "Class"
    • "Method"
    • "Init-method"
    • "Getter"
    • "Setter"
    • "Field"
  • name:



    • public field or method: name - string or character property key
    • private field or method: none
    • class: absent
  • isStatic:



    • static field or method: true
    • instance field or method: false
    • class: absent


The "Target" (constructor or prototype) is not passed to the field or method decorators because it (the "target") has not yet been constructed at the time the decorator is called.



Return value



The return value depends on the type of the decorator:



  • class: new class
  • method, getter or setter: new function
  • field: an object with three properties:



    • get
    • set
    • initialize: a function called with the same argument as set, returning the value used to initialize the variable. This function is called when the setting of the underlying storage depends on the field initializer or method definition
  • init method: an object with two properties:



    • method: a function that replaces a method
    • initialize: a function with no arguments, whose return value is ignored, and which is called with the newly created object as the receiver


3. Using decorators



Decorators are applied after they are called. The intermediate stages of the decorator's algorithm cannot be fixed - the newly created class is inaccessible until all the decorators of the methods and instance fields are applied.



Class decorators are called after field and method decorators have been applied.



Finally, static field decorators are applied.



Semantics of field decorators



A class field decorator is a getter / setter pair for a private field. Therefore the code:



function id(v) { return v }

class C {
  @id x = y
}

      
      





has the following semantics:



class C {
  //  #    -
  #x = y
  get x() { return this.#x }
  set x(v) { this.#x = v }
}

      
      





Field decorators behave like private fields. The following code will throw a TypeError exception because we are trying to access "y" before adding it to the instance:



class C {
  @id x = this.y
  @id y
}
new C // TypeError

      
      





The getter / setter pair are normal object methods, which are non-enumerable (non-enumerable, if you will) like other methods. The private fields it contains are added one by one, along with the initializers, like ordinary private fields.



Design goals



  • It should be as easy to use the built-in decorators as it should to write your own
  • Decorators should only be applied to decorated objects without side effects.


Application cases



  • Storing metadata in classes and methods
  • Converting a field to an accessor
  • Wrapping a method or class (this use of decorators is somewhat similar to object proxying)


Examples of



Examples of implementation and use of decorators.



@logged



The @logged decorator prints messages to the console about the start and end of method execution. There are other popular decorators that wrap functions like: @deprecated. debounce, @memoize, etc.



Using:



//  .mjs   -
import { logged } from './logged.mjs'

class C {
  @logged
  m(arg) {
    this.#x = arg
  }

  @logged
  set #x(value) { }
}

new C().m(1)
//  m   1
//  set #x   1
//  set #x
//  m

      
      





@logged can be implemented in JavaScript as a decorator. A decorator is a function that is called with an argument containing the element to decorate. This element can be a method, getter, or setter. Decorators can be called with a second argument, the context, however, in this case we don't need it.



The value returned by the decorator replaces the wrapped element. For methods, getters, and setters, the return value is the function that replaces them.



// logged.mjs

export function logged(f) {
  //   
  const name = f.name
  function wrapped(...args) {
    //    
    console.log(` ${name}   ${args.join(', ')}`)
    //    
    const ret = f.call(this, ...args)
    //    
    console.log(` ${name}`)
    //  
    return ret
  }
  // Object.defineProperty()       
  // https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
  Object.defineProperty(wrapped, 'name', { value: name, configurable: true })
  //  
  return wrapped
}

      
      





The result of the transpilation of the given example may look like this:



let x_setter

class C {
  m(arg) {
    this.#x = arg
  }

  static #x_setter(value) { }
  //  -     (class static initialization blocks)
  // https://github.com/tc39/proposal-class-static-block
  static { x_setter = C.#x_setter }
  set #x(value) { return x_setter.call(this, value) }
}

C.prototype.m = logged(C.prototype.m, { kind: "method", name: "m", isStatic: false })
x_setter = logged(x_setter, {kind: "setter", isStatic: false})

      
      





Note that getters and setters are decorated separately. Accessors (computed properties) are not combined as in previous clauses.



@defineElement



HTML Custom Elements (custom elements, part of web components) allows you to create your own HTML elements. Registration of elements is done using customElements.define . Here's how you can register an element using decorators:



import { defineElement } from './defineElement.js'

@defineElement('my-class')
class MyClass extends HTMLElement { }

      
      





Classes can be decorated along with methods and accessors.



// defineElement.mjs
export function defineElement(name, options) {
  return klass => {
    customElements.define(name, klass, options); return klass
  }
}

      
      





The decorator takes arguments that it uses itself, so it is implemented as a function that returns another function. You can think of this as a "decorator factory": after passing arguments, you get a different decorator.



Decorators adding metadata



Decorators can provide metadata to class members by adding a metadata property to the context object passed to them. All objects containing metadata are concatenated using Object.assign and placed in the [Symbol.metadata] class property. For example:



//    
@annotate({x: 'y'}) @annotate({v: 'w'}) class C {
  //    
  @annotate({a: 'b'}) method() { }
  //    
  @annotate({c: 'd'}) field
}

C[Symbol.metadata].class.x                    // 'y'
C[Symbol.metadata].class.v                    // 'w'
// ,  ,    ,
C[Symbol.metadata].prototype.methods.method.a // 'b'
//   
C[Symbol.metadata].instance.fields.field.c    // 'd'

      
      





Please note that the presentation format of the annotated object is approximate and can be further refined. The main task of the example is to show that an annotation is just an object that does not require the use of libraries to read or write data to it; it is created by the system automatically.



The decorator in question can be implemented like this:



function annotate(metadata) {
  return (_, context) => {
    context.metadata = metadata
    return _
  }
}

      
      





Each time the decorator is called, a new context is passed to it, then the metadata property, provided that it is not undefined, is included in [Symbol.metadata].



Note that metadata added to the class itself, and not to its method, is not available to decorators declared in the class. Adding metadata to a class occurs in the constructor after calling all the "internal" decorators to avoid data loss.



@tracked



The @tracked decorator observes the class field and calls the render method when the setter is called. This pattern and similar patterns are widely used by various frameworks to solve the problem of re-rendering.



The semantics of decorated fields suggests a getter / setter wrapper around some private data store. @tracked can wrap a getter / setter pair to implement re-rendering logic:



import {tracked} from './tracked.mjs'

class Element {
  @tracked counter = 0

  increment() { this.counter++ }

  render() { console.log(counter) }
}

const e = new Element()
e.increment() //    1
e.increment() // 2

      
      





When decorating a field, the "wrapped" value is an object with two properties: get and set functions for managing internal storage. They are designed to automatically bind to an instance (using call ()).



// tracked.mjs
export function tracked({ get, set }) {
  return {
    get,
    set(value) {
      if (get.call(this) !== value) {
        set.call(this, value)
        this.render()
      }
    }
  }
}

      
      





Limited access to private fields and methods



At times, some code outside the class may need to access private fields or methods. For example, to provide interoperability between two classes or to test code within a class.



Decorators make it possible to access private fields and methods. This logic can be encapsulated in an object with private reference keys provided as needed.



import { PrivateKey } from './private-key.mjs'

let key = new PrivateKey()

export class Box {
  @key.show #contents
}

export function setBox(box, contents) {
  return key.set(box, contents)
}

export function getBox(box) {
  return key.get(box)
}

      
      





Note that the above example is a kind of hack that is easier to implement with constructs like referencing private names with private.name or extending the scope of private names with private / with . However, it shows how this proposal organically expands the existing functionality.



// private-key.mjs
export class PrivateKey {
#get
#set

show({ get, set }) {
  assert(this.#get === undefined && this.#set === undefined)
  this.#get = get
  this.#set = set
  return { get, set }
}
get(obj) {
  return this.#get.call(obj)
}
set(obj, value) {
  return this.#set.call(obj, value)
}
}

      
      





@deprecated



The @deprecated decorator prints a warning to the console about using deprecated fields, methods, or accessors. Usage example:



import { deprecated } from './deprecated.mjs'

export class MyClass {
  @deprecated field

  @deprecated method() { }

  otherMethod() { }
}

      
      





In order to allow the decorator to work with different elements of the class, the kind field of the context informs the decorator of the type of the syntactic construct recognized as obsolete. This technique also allows you to throw exceptions when the decorator is used in an invalid context, for example: an inner class cannot be marked as deprecated because it cannot be denied access.



function wrapDeprecated(fn) {
  let name = fn.name
  function method(...args) {
    console.warn(` ${name}  `)
    return fn.call(this, ...args)
  }
  Object.defineProperty(method, 'name', { value: name, configurable: true })
  return method
}

export function deprecated(element, { kind }) {
  switch (kind) {
    case 'method':
    case 'getter':
    case 'setter':
      return wrapDeprecated(element)
    case 'field': {
      let { get, set } = element
      return { get: wrapDeprecated(get), set: wrapDeprecated(set) }
    }
    default:
      //  'class'
      throw new Error(`${kind}    @deprecated`)
  }
}

      
      





Method Decorators Requiring Preconfiguration



Some method decorators rely on executing code when the class is instantiated. For example:



  • The @on ('event') decorator for class methods extends HTMLElement, which registers this method as an event handler in the constructor
  • The @bound decorator is equivalent to this.method = this.method.bind (this) in the constructor


There are different ways to use the named decorators.



Option 1: constructors and metadata



These decorators are a combination of metadata and a mixin containing initialization operations that are used in the constructor.



@on with a touch



class MyClass extends WithActions(HTMLElement) {
  @on('click') clickHandler() {}
}

      
      





The specified decorator can be defined like this:



//         ,
//   Symbol
// https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Symbol
const handler = Symbol('handler')
function on(eventName) {
  return (method, context) => {
    context.metadata = { [handler]: eventName }
    return method
  }
}

class MetadataLookupCache {
  //     ,
  //      WeakMap
  // https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/WeakMap
  #map = new WeakMap()
  #name
  constructor(name) { this.#name = name }
  get(newTarget) {
    let data = this.#map.get(newTarget)
    if (data === undefined) {
      data = []
      let klass = newTarget
      while (klass !== null && !(this.#name in klass)) {
        for (const [name, { [this.#name]: eventName }] of Object.entries(klass[Symbol.metadata].instance.methods)) {
          if (eventName !== undefined) {
            data.push({ name, eventName })
          }
        }
        klass = klass.__proto__
      }
      this.#map.set(newTarget, data)
    }
    return data
  }
}

const handlersMap = new MetadataLookupCache(handler)

function WithActions(superClass) {
  return class C extends superClass {
    constructor(...args) {
      super(...args)
      const handlers = handlersMap.get(new.target, C)
      for (const { name, eventName } of handlers) {
        this.addEventListener(eventName, this[name].bind(this))
      }
    }
  }
}

      
      





@bound with a mixin



@bound can be used like this:



class C extends WithBoundMethod(Object) {
  #x = 1
  @bound method() { return this.#x }
}

const c = new C()
const m = c.method
m() // 1,   TypeError

      
      





The decorator implementation might look like this:



const boundName = Symbol('boundName')
function bound(method, context) {
  context.metadata = { [boundName]: true }
  return method
}

const boundMap = new MetadataLookupCache(boundName)

function WithBoundMethods(superClass) {
  return class C extends superClass {
    constructor(...args) {
      super(...args)
      const names = boundMap.get(new.target, C)
      for (const { name } of names) {
        this[name] = this[name].bind(this)
      }
    }
  }
}

      
      





Note that MetadataLookupCache is used in both examples. Also, keep in mind that this and the following sentence assumes the use of some kind of standard library for adding metadata.



Option 2: method decorators init



Decorator init: intended for cases where an initialization operation is required, but it is not possible to call the superclass / mixin. It allows such operations to be added when the constructor is executed.



@on c init



Using:



class MyElement extends HTMLElement {
  @init: on('click') clickHandler()
}

      
      





Decorator init: Called just like method decorators, but returns a {method, initialize} pair, where initialize is called with a new instance as the this value, no arguments, and returns nothing.



function on(eventName) {
  return (method, context) => {
    assert(context.kind === 'init-method')
    return { method, initialize() { this.addEventListener(eventName, method) } }
  }
}

      
      





@bound with init



init: can also be used to build a decorator init: bound:



class C {
  #x = 1
  @init: bound method() { return this.#x }
}

const c = new C()
const m = c.method
m() // 1,   TypeError

      
      





The @bound decorator can be implemented like this:



function bound(method, { kind, name }) {
  assert(kind === 'init-method')
  return { method, initialize() { this[name] = this[name].bind(this) } }
}

      
      





For more information on the limitations of use, as well as open questions that developers have to solve before standardizing decorators in JavaScript, refer to the text of the proposal at the link provided at the beginning of the article.



On this, let me take my leave. Thank you for attention.



All Articles