Decorators: A New Proposal

Chris Garrett and Dan Ehrenberg

2020-09

A quick refresher...

Stage 1/TypeScript

Stage 1/TypeScript

function readonly(prototype, key, desc) {
  return {
    ...desc,
    writable: false,
  };
}

TypeScript

class Foo {
  @deprecate bar = 123;
}
class Foo {
  get foo() {
    console.warn('"foo" is deprecated');
    return this._foo;
  }
  
  set foo(val) {
    console.warn('"foo" is deprecated');
    this._foo = val;
  }
  
  constructor() {
    this.foo = 123;
  }
}

Stage 1

class Foo {
  @deprecate bar = 123;
}
// Field initializer has to be hoisted
function initializer() {
  return 123;
}

class Foo {
  get foo() {
    console.warn('"foo" is deprecated');
    
    if (!('_foo' in this)) {
      this._foo = initializer.call(this);
    }
      
    return this._foo;
  }
  
  set foo(val) {
    console.warn('"foo" is deprecated');
    this._foo = val;
  }
}

Main issues

  • Too dynamic - mutated the prototype and changed the shape of classes in unpredictable ways, dynamically
  • Didn't work with class fields (esp. with [[Define]] semantics)
  • Didn't work with private fields/methods (can't expose key)
  • Not extensible to other types of decorators (functions, parameters, etc)

Stage 1/TypeScript

Stage 2

Stage 2

// Create a bound version of the method as a field
function bound(elementDescriptor) {
  let { kind, key, method, enumerable, configurable, writable } = elementDescriptor;
  
  assert(kind == "method");
  
  function initialize() {
    return method.bind(this);
  }
  
  // Return both the original method and a bound function field that calls the method.
  // (That way the original method will still exist on the prototype, avoiding
  // confusing side-effects.)
  return {
    ...elementDescriptor,
    extras: [
      { 
        kind: "field", 
        key, 
        placement: "own", 
        enumerable, 
        configurable, 
        writable, 
        initialize 
      }
    ]
  }
}

Main issues

  • Still too dynamic, could change the shape of classes in dynamic ways, not known until execution
  • Still not really extensible to other types of decorators - based on extended property descriptors
  • Could be confusing to developers, as the outcome of applying decorator was not predictable

Stage 2

Stage 2

class Foo {
  @removeAndDuplicate(9) bar = 123;
}
class Foo {
  bar1 = 123;
  bar2 = 123;
  bar3 = 123;
  bar4 = 123;
  bar5 = 123;
  bar6 = 123;
  bar7 = 123;
  bar8 = 123;
  bar9 = 123;
}

Constraints

  • If decorators change the shape of the class, the change should be knowable from the syntax.
  • Decorators should not be capable of doing things that would be unexpected or unpredictable to developers.

Stage 2

Static Decorators

Static Decorators

export decorator @tracked {
  @initialize((instance, name, value) => {
    instance[`__internal_${name}`] = value;
  })
  @register((target, name) => {
    Object.defineProperty(target, name, {
      get() { return this[`__internal_${name}`]; },
      set() { this[`__internal_${name}`] = value; this.render(); },
      configurable: true
    });
  })
}

Main issues

  • Very complicated
    • Required a new namespace for decorators
    • Modules had to be sorted to parse definitions before usages
  • Difficult to polyfill, and would cause a lot of interop issues
  • Still could potentially be confusing to developers - decorators could do many things, hard to predict what applying a decorator would do

Static Decorators

The New Proposal

  • Static decorators was driven by the idea that we had to somehow solve conflicting use cases
  • Early on the decorators working group surveyed real world usage, top packages in the ecosystem using decorators today: Survey link
  • Based on the survey, found that all use cases can conform more or less to a single transformation per type of decorated element
  • Decorators are plain JavaScript functions
  • Decorators decorate syntactic elements and their associated value
  • Have exactly one meaning per syntactic element:
    • Decorates value OR
    • Decorates initialization + mutation

New Proposal Semantics

function decorate() {
  // ...
}

// Applies to all existing decorators
@decorate
class Example {
  @decorate myField = 123;
  
  @decorate myMethod() {
    // ...
  }
  
  @decorate get myAccessor() {
    // ...
  }
}

// And many potential future ones...
@decorate
function example(@decorate param) {
  // ...
}

let @decorate myVar = 123;

Class Decorators

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

function defineElement(name, options) {
  return (klass, context) => {
    customElements.define(
      name, 
      klass, 
      options
    );
    return klass;
  }
}
  • Class decorators receive the original class definition, and return a new one

Class Decorators

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

function defineElement(name, options) {
  return (klass, context) => {
    customElements.define(
      name, 
      klass, 
      options
    );
    return klass;
  }
}
class MyClass extends HTMLElement { }

MyClass = defineElement('my-class')(
  MyClass, 
  {kind: "class"}
);

function defineElement(name, options) {
  // ...
}

Class Decorators

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

function defineElement(name, options) {
  return (klass, context) => {
    customElements.define(
      name, 
      klass, 
      options
    );
    return klass;
  }
}
class MyClass extends HTMLElement { }

MyClass = defineElement('my-class')(
  MyClass, 
  {kind: "class"}
);

function defineElement(name, options) {
  // ...
}

Class Decorators

class MyClass extends HTMLElement { }

MyClass = defineElement('my-class')(
  MyClass, 
  {kind: "class"}
);

function defineElement(name, options) {
  // ...
}

Context object containing

  • Kind of decorated element
  • Key, if applicable
  • Other information (isStatic, etc)
  • Method for adding metadata (discussed later)

Method Decorators

class C {
  @logged
  m(arg) { }
}

function logged(f) {
  const name = f.name;
  return function(...args) {
    console.log(`starting ${name}`);
    const ret = f.call(this, ...args);
    console.log(`ending ${name}`);
    return ret;
  }
}

new C().m(1);
// starting m
// ending m
  • Method decorators receive the original method, return a new one.

Method Decorators

class C {
  @logged
  m(arg) { }
}

function logged(f) {
  const name = f.name;
  return function(...args) {
    console.log(`starting ${name}`);
    const ret = f.call(this, ...args);
    console.log(`ending ${name}`);
    return ret;
  }
}

new C().m(1);
// starting m
// ending m
class C {
  m(arg) { }
}

C.prototype.m = logged(
  C.prototype.m, 
  { 
    kind: "method", 
    key: "m", 
    isStatic: false 
  }
);

function logged(f) {
  // ...
}

Methods with Initialization

  • Example use cases: `@bound`, `@on`
  • Two potential solutions
    • Combine method decorators with class decorators/inheritance
    • Add a new `@init:` style invocation which signals that a method decorator adds some initialization logic

Methods with Init

// class decorator solution
@withBound
class C {
  #x = 1;
  @bound method() { return this.#x; }
}
// @init: solution
class C {
  #x = 1;
  @init: bound method() { return this.#x; }
}

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

Accessor Decorators

class C {
  @logged
  set x(value) { }
}

function logged(f) {
  const name = f.name;
  return function(...args) {
    console.log(`starting ${name}`);
    const ret = f.call(this, ...args);
    console.log(`ending ${name}`);
    return ret;
  }
}
  • Accessors are functions, like methods, that exist of the prototype.
  • Signature is for the most part the same as method decorators, with different `kind`
  • `get` and `set` are decorated independently

Accessor Decorators

class C {
  @logged
  set x(value) { }
}

function logged(f) {
  const name = f.name;
  return function(...args) {
    console.log(`starting ${name}`);
    const ret = f.call(this, ...args);
    console.log(`ending ${name}`);
    return ret;
  }
}
class C {
  set x(value) { }
}
 	
let { set } = Object.getOwnPropertyDescriptor(
  C.prototype, "x"
);

set = logged(
  set, 
  { kind: "setter", isStatic: false }
);

Object.defineProperty(C.prototype, "x", { set });

function logged(f) {
  // ...
}

Field Decorators

  • Majority of field decoration use cases in the survey were for intercepting access and mutation
  • Remaining cases were all metadata only, using TypeScript's experimental type-emit feature (e.g. typed-orm, type-aware-json)
  • Metadata + types is out of scope for this feature, so main behavior is to solve for the use cases that intercept access and mutation

Field Decorators

class Element {
  @tracked counter = 0;

  increment() { this.counter++; }

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

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

const e = new Element();
e.increment();  // logs 1
e.increment();  // logs 2
  • A field is a value that can be mutated, so it's a mutator decorator
  • Decorators receive a `get` and `set` which can be used to access and update the decorated value
  • Return a new `get` and `set` which wrap the original. This new get and set are defined on the prototype of the class.
  • Can also optionally return `initialize`, which modifies initial value

Field Decorators

class Element {
  @tracked counter = 0;

  increment() { this.counter++; }

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

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

const e = new Element();
e.increment();  // logs 1
e.increment();  // logs 2
class Element {
  #counter = initialize(0);
  get counter() { return this.#counter; }
  set counter(v) { this.#counter = v; }

  increment() { this.counter++; }

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

let originalDesc = Object.getOwnPropertyDescriptor(
  Element.prototype, "counter"
);
                 
let { get, set, initialize = v => v } = tracked(
  originalDesc, 
  { kind: "field", name: "counter", isStatic: false }
);
                 
Object.defineProperty(
  Element.prototype, 
  "counter", 
  { get, set }
);
                 
function tracked() {
  //...
}

Metadata

class Element {
  @tracked counter = 0;

  increment() { this.counter++; }

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

const IS_TRACKED = Symbol();

function tracked({get, set}, context) {
  context.metadata = IS_TRACKED;
  
  return {
    get,
    set(value) {
      if (get.call(this) !== value) {
        set.call(this, value);
        this.render();
      }
    }
  };
}

function isTracked(C, key) {
  return C[Symbol.metadata][key] === IS_TRACKED;
}

  • Added via setting the `metadata` field on the context object passed to a decorator
  • Accessed via [Symbol.metadata] on the class

Open Questions

  • Should accessors be decorated separately?
  • Is the metadata format acceptable? Should any utilities be added for querying it?
  • Should `@init:` decorators be included in this proposal?
  • Should parameter decorators be included in this proposal?
  • Are the API surface details as they should be?

Standardization Plan

  • Assuming feedback is good
    • Continue iterating in stage 2
    • Write up a spec and begin implementation in experimental transpilers
    • Begin collecting feedback from JS developers
    • Iterate on open questions in the working group, bring updates to TC39
    • Propose for stage 3 no sooner than six months from this meeting, so we have proper time to collect feedback

Questions?

@Thanks!