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!
Decorators: A New Proposal
By pzuraq
Decorators: A New Proposal
- 3,699