Decorators are a proposal for extending JavaScript classes which is widely adopted among developers in transpiler environments, with broad interest in standardization. TC39 has been iterating on decorators proposals for over five years. This document describes a new proposal for decorators based on elements from all past proposals.
Decorators @decorator
are functions called on class elements or other JavaScript syntax forms during definition, potentially wrapping or replacing them with a new value returned by the decorator.
A decorated class field is treated as wrapping a getter/setter pair for accessing that storage. Decorated storage is useful for observation/tracking, which has been a pain point for the original legacy/experimental decorators combined with [[Define]] semantics for class fields. These semantics are based on Michel Weststrate's "trapping decorators" proposal.
Decorators may also annotate a class element with metadata. These are simple, unrestricted object properties, which are collected from all decorators which add them, and made available as a set of nested objects in the [Symbol.metadata]
property.
By making decorators always simply wrap what they are decorating, rather than performing other transformations, this proposal aims to meet the following requirements:
- The class "shape" is visible without executing the code, making decorators more optimizable for engines.
- Implementations can work fully on a per-file basis, with no need for cross-file knowledge.
- No new namespace or type of second-class value is added--decorators are functions.
A few examples of how to implement and use decorators in this proposal:
The @logged
decorator logs a console message when a method starts and finishes. Many other popular decorators will also want to wrap a function, e.g., @deprecated
, @debounce
, @memoize
, etc.
Usage:
import { logged } from "./logged.mjs";
class C {
@logged
m(arg) {
this.#x = arg;
}
@logged
set #x(value) { }
}
new C().m(1);
// starting m with arguments 1
// starting set #x with arguments 1
// ending set #x
// ending m
@logged
can be implemented in JavaScript as a decorator. Decorators are functions that are called with an argument containing what's being decorated. For example:
- A decorated method is called with the method being decorated
- A decorated getter is called with the getter function being decorated
- A decorated setter is called with the setter function being decorated
(Decorators are called with a second parameter giving more context, but we don't need those details for the @logged
decorator.)
The return value of a decorator is a new value that replaces the thing it's wrapping. For methods, getters and setters, the return value is another function to replace that method, getter or setter.
// logged.mjs
export function logged(f) {
const name = f.name;
function wrapped(...args) {
console.log(`starting ${name} with arguments ${args.join(", ")}`);
const ret = f.call(this, ...args);
console.log(`ending ${name}`);
return ret;
}
wrapped.name = name;
return wrapped;
}
This example roughly "desugars" to the following (i.e., could be transpiled as such):
let x_setter;
class C {
method(arg) {
this.#x = arg;
}
static #x_setter(value) { }
static #_ = (x_setter = C.#x_setter, void 0);
set #x(value) { return x_setter.call(this, value); }
}
C.prototype.method = logged(C.prototype.method, { kind: "method", name: "m", isStatic: false });
x_setter = logged(x_setter, {kind: "setter", isStatic: false});
Note that getters and setters are decorated separately. Accessors are not "coalesced" as in earlier decorators proposals (unless they are generated for a field; see below).
HTML Custom Elements lets you define your own HTML element. Elements are registered using customElements.define
. Using decorators, the registration can happen up-front:
import { defineElement } from "./defineElement.mjs";
@defineElement('my-class')
class MyClass extends HTMLElement { }
Classes can be decorated just like methods and accessors. The class shows up in the value
option.
// defineElement.mjs
export function defineElement(name, options) {
return klass => { customElements.define(name, klass, options); return klass; }
}
The decorator takes arguments at its usage site, so it is implemented as a function that returns another function. You can think of it as a "decorator factory": after you apply the arguments, it gives you another decorator.
This decorator usage could be desugared as follows:
class MyClass extends HTMLElement { }
MyClass = defineElement(MyClass, {kind: "class"});
Decorators can add metadata about class elements by adding a metadata
property of the context object that is passed in to them. All of the metadata objects are Object.assign
'ed together and placed in a property reachable from [Symbol.metadata]
on the class. 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 // "a"
C[Symbol.metadata].instance.fields.field.c // "d"
NOTE: The exact format of the annotations object is not very well-thought-out and could use more refinement. The main thing I'd like to illustrate here is, it's just an object, with no particular support library to read or write it, and it's automatically created by the system.
This decorator @annotate
could be implemented as follows:
function annotate(metadata) {
return (_, context) => {
context.metadata = metadata;
return _;
}
}
Each time a decorator is called, it is passed a new context object, and after each decorator returns, the context object's metadata
property is read, and if it's not undefined, it's included in the [Symbol.metadata]
for that class element.
Note that, since metadata is held on the class, not on the method, the metadata is not visible to earlier decorators. Metadata on classes is added to the constructor after all class decorators have run so that they are not lost by later wrapping.
The @tracked
decorator watches a field and triggers a render()
method when the setter is called. This pattern, or patterns like it, is common in frameworks to avoid extra bookkeeping scattered throughout the application to ask for re-rendering.
Decorated fields have the semantics of getter/setter pairs around an underlying piece of private storage. The decorators can wrap these getter/setter functions. @tracked
can wrap this getter/setter pair to implement the re-rendering behavior.
import { tracked } from "./tracked.mjs";
class Element {
@tracked counter = 0;
increment() { this.counter++; }
render() { console.log(counter); }
}
const e = new Element();
e.increment(); // logs 1
e.increment(); // logs 2
When fields are decorated, the "wrapped" value is an object with two properties: get
and set
functions that manipulate the underlying storage. They are built to be .call()
ed with the instance of the class as a receiver. The decorator can then return a new object of the same form. (If one of the callbacks is missing, then it is left in place unwrapped.)
// tracked.mjs
export function tracked({get, set}) {
return {
get,
set(value) {
if (get.call(this) !== value) {
set.call(this, value);
this.render();
}
}
};
}
This example could be roughly desugared as follows:
let initialize, get, set;
class Element {
#counter = initialize(0);
get counter() { return this.#counter; }
set counter(v) { this.#counter = v; }
increment() { this.counter++; }
render() { console.log(counter); }
}
{ get, set } = Object.getOwnPropertyDescriptor(Element.prototype, "counter");
{ get, set, initialize } = tracked({get, set}, { kind: "field", name: "counter", isStatic: false })
Object.defineProperty(Element.prototype, "counter", {get, set});
Sometimes, certain code outside of a class may need to access private fields and methods. For example, two classes may be "collaborating", or test code in a different file needs to reach inside a class.
Decorators can make this possible by giving someone access to a private field or method. This may be encapsulated in a "private key"--an object which contains these references, to be shared only with who's appropriate.
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 this is a bit of a hack, and could be done better with constructs like references to private names with private.name
and broader scope of private names with private
/with
. But it shows that this decorator proposal "naturally" exposes existing things in a useful way.
// 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(obj);
}
set(obj, value) {
return this.#set(obj, value);
}
}
This example could be roughly desugared as follows:
let initialize, get, set;
export class Box {
#_contents = initialize(undefined);
get #contents() { return get.call(this); }
set #contents(v) { set.call(this, v); }
static #_ = (
get = function() { return this.#_contents; },
set = function(v) { this.#_contents = v; }
)
}
{get, set, initialize} = key.show({get, set}, {kind: "field", isStatic: false});
The @deprecated
decorator prints warnings when a deprecated field, method or accessor is used. As an example usage:
import { deprecated } from "./deprecated.mjs"
export class MyClass {
@deprecated field;
@deprecated method() { }
otherMethod() { }
}
To allow the deprecated
to work on different kinds of class elements, the kind
field of the context object lets decorators see which kind of syntactic construct they are deprecating. This technique also allows an error to be thrown when the decorator is used in a context where it can't apply--for example, the entire class cannot be marked as deprecated, since there is no way to intercept its access.
// deprecated.mjs
function wrapDeprecated(fn) {
let name = fn.name
function method(...args) {
console.warn(`call to deprecated code ${name}`);
return fn.call(this, ...args);
}
method.name = name;
return method;
}
export function deprecated(element, {kind}) {
switch (kind) {
case 'method':
case 'getter':
case 'setter':
return wrapDecorated(element);
case 'field': {
let { get, set } = element;
return { get: wrapDeprecated(get), set: wrapDeprecated(set) };
}
default: // includes 'class'
throw new Error(`Unsupported @deprecated target ${kind}`);
}
}
The desugaring here is analogous to the above examples, which show the use of kind
.
Some method decorators are based on executing code when the class instance is being created. For example:
- A
@on('event')
decorator for methods on classes extendingHTMLElement
which registers that method as an event listener in the constructor. - A
@bound
decorator, which does the equivalent ofthis.method = this.method.bind(this)
in the constructor. This idiom meets Jordan Harband's goal of being friendlier to monkey-patching than the popular idiom of using an arrow function in a field initializer.
These decorators can be built with the combination of metadata, and a mixin which performs the initialization actions in its constructor.
class MyElement extends WithActions(HTMLElement) {
@on('click') clickHandler() { }
}
This decorator could be defined as follows:
const handler = Symbol("handler");
function on(eventName)
return (method, {name}) => {
context.metadata = {[handlers]: eventName};
return method;
}
}
let handlersMap = new WeakMap(); // new.target -> [{method, eventname}]
function getHandlers(newTarget, mixinClass) {
let handlers = handlersMap.get(newTarget);
if (handlers === undefined) {
handlers = [];
while (newTarget !== null && klass !== mixinClass) {
for (const [name, {[handlers]: eventName}]
of Object.entries(klass[Symbol.metadata].instance.methods)) {
if (eventName !== undefined) {
handlers.push({method: klass.prototype[name], eventName});
}
}
klass = klass.__proto__;
}
handlersMap.set(newTarget, handlers)
}
return handlers;
}
function WithActions(superclass) {
return class C extends superclass {
constructor(...args) {
super(...args);
let handlers = getHandlers(new.target, C);
for (const {method, eventName} of handlers) {
this.addEventListener(eventName, method);
}
}
}
}
@bound
could be used with a mixin superclass as follows:
class C extends WithBoundMethod(Object) {
#x = 1;
@bound method() { return this.#x; }
}
let c = new C;
let m = c.method;
m(); // 1, not TypeError
This decorator could be defined as:
const boundName = Symbol("boundName");
function bound(method, context) {
context.metadata = {[boundName]: true};
return method;
}
let boundNameMap = new WeakMap(); // new.target -> [names]
function getBoundNames(newTarget, mixinClass) {
let names = boundNameMap.get(newTarget);
if (names === undefined) {
names = [];
while (newTarget !== null && klass !== mixinClass) {
for (const [name, annotations] of
Object.entries(klass[Symbol.metadata].instance.methods)) {
if (annotations[boundName]) {
handlers.push(name);
}
}
klass = klass.__proto__;
}
boundNameMap.set(newTarget, names)
}
return names;
}
function WithBoundMethods(superclass) {
return class C extends superclass {
constructor(...args) {
super(...args);
let names = getBoundNames(new.target, C);
for (const name of names) {
this[name] = this[name].bind(this);
}
}
}
}
If it's not acceptable to require a superclass/mixin for cases requiring initialization action, an The init
keyword in a method declaration changes a method into an "init method". This keyword allows decorators to add initialization actions, run when the constructor executes.
Usage:
class MyElement extends HTMLElement {
@on('click') init clickHandler() { }
}
An "init method" (method declared with init
) is called similarly to a method decorator, but it is expected to return a pair {method, initialize}
, where initialize
is called with the this
value being the new instance, taking no arguments and returning nothing.
function on(eventName) {
return (method, context) => {
assert(context.kind === "init-method");
return {method, initialize() { this.addEventListener(eventName, method); }};
}
}
The class definition would be desugared roughly as follows:
let initialize;
class MyElement extends HTMLElement {
clickHandler() { }
constructor(...args) {
super(...args);
initialize.call(this);
}
}
{method: MyElement.prototype.clickHandler, initialize} =
on('click')(MyElement.prototype.clickHandler,
{kind: "init-method", isStatic: false, name: "clickHandler"});
The init
keyword for methods can also be used to build a @bound
decorator, used as follows:
class C {
#x = 1;
@bound init method() { return this.#x; }
}
let c = new C;
let m = c.method;
m(); // 1, not TypeError
The @bound
decorator can be implemented as follows:
function bound(method, {kind, name}) {
assert(kind === "init-method");
return {method, initialize() { this[name] = this[name].bind(this); }};
}
This decorators proposal uses the syntax of the previous Stage 2 decorators proposal. This means that:
- Decorator expressions are restricted to a chain of variables, property access with
.
but not[]
, and calls()
. To use an arbitrary expression as a decorator,@(expression)
is an escape hatch. - Class expressions may be decorated, not just class declarations`.
- Class decorators come after
export
anddefault
.
There is no special syntax for defining decorators; any function can be applied as a decorator.
The three steps of decorator evaluation:
- Decorator expressions and
s (the thing after the
@
) are evaluated interspersed with computed property names. - Decorators are called (as functions) during class definition, after the methods have been evaluated but before the constructor and prototype have been put together.
- Decorators are applied (mutating the constructor and prototype) all at once, after all of them have been called.
The semantics here generally follow the consensus at the May 2016 TC39 meeting in Munich.
Decorators are evaluated as expressions, interspersed in their evaluation order with computed property names. This goes left to right, top to bottom. The result of decorators is stored in the equivalent of local variables to be later called after the class definition initially finishes executing.
The first parameter, of what the decorator is wrapping, depends on what is being decorated:
- In a method, init-method, getter or setter decorator: the relevant function object
- In a class decorator: the class
- In a field: An object with two properties
get
: A function which takes no arguments, expected to be called with a receiver which is the appropriate object, returning the underlying value.set
: A function which takes a single argument (the new value), expected to be called with a receiver which is the object being set, expected to returnundefined
.
The context object--the object passed as the second argument to the decorator--contains the following properties:
kind
: One of"class"
"method"
"init-method"
"getter"
"setter"
- `"field"``
name
:- Public field or method: the
name
is the String or Symbol property key. - Private field or method: missing (could be provided as some representation of the private name, in a follow-on proposal)
- Class: missing
- Public field or method: the
isStatic
:- Static field or method:
true
- Instance field or method:
false
- Class: missing
- Static field or method:
The "target" (constructor or prototype) is not passed to field or method decorators, as it has not yet been built when the decorator runs.
The return value is interpreted based on the type of decorator. The return value is expected as follows:
- Class: A new class
- Method, getter or setter: A new function
- field: An object with three properties (each individually optional):
get
: A function of the same form as theget
property of the first argumentset
: Ditto, forset
initialize
: A called with the same arguments asset
, which returns a value which is used for the initializing set of the variable. This is called when initially setting the underlying storage based on the field initializer or method definition. This method shouldn't call theset
input, as that would trigger an error. Ifinitialize
isn't provided,set
is not called, and the underlying storage is written directly. This way,set
can count on the field already existing, and doesn't need to separately track that.
- Init method: An object with the properties
method
: A function to replace the methodinitialize
: A function with no arguments, whose return value is ignored, which is called with the newly constructed object as the receiver.
Decorators are applied after all decorators have been called. The intermediate steps of the decorator application algorithm are not observable--the newly constructed class is not made available until after all method and non-static field decorators have been applied.
The class decorator is called only after all method and field decorators are called and applied.
Finally, static fields are executed and applied.
Decorated fields have the semantics of getter-setter pairs backed by a private field. That is,
function id(v) { return v; }
class C {
@id x = y;
}
has the semantics of
class C {
#x = y;
get x() { return this.#x; }
set x(v) { this.#x = v; }
}
These semantics imply that decorated fields have "TDZ" like private fields. For example, the following is a TypeError because y
is accessed before it is added to the instance.
class C {
@id x = this.y;
@id y;
}
new C; // TypeError
The getter/setter pair are ordinary JS method objects, and non-enumerable like other methods. The underlying private fields are added one-by-one, interspersed with initializers, just like ordinary private fields.
This decorators proposal was designed to permit other syntactic elements to be decorated:
Annotations, a declarative complement to decorators, use the syntax @{ }
, which behaves exactly like an object literal. Arbitrary expressions, spread, computed property names, etc, are permitted. It is accessible from the annotated object through the [Symbol.metadata]
property.
@{x: "y"} @{v: "w"} class C {
@{a: "b"} method() { }
@{c: "d"} field;
}
C[Symbol.metadata].class.x // "y"
C[Symbol.metadata].class.v // "w"
C[Symbol.metadata].prototype.methods.method.a // "a"
C[Symbol.metadata].instance.fields.field.c // "d"
Annotations must always have a literal @{
to start them. To use an existing object as an annotation, you can use the syntax @{ ...obj }
. The entirity of object syntax is available, including computed property names, arbitrary expressions as values, shorthand names, concise methods, etc.
Libraries and frameworks which want to establish consistent conventions for using annotations may do so based on a Symbol property key that they export. Annotations have the potential advantage in load time performance that engines can directly execute them, as they are as declarative as an object literal.
Annotation semantics may be useful for cases like ORMs and serialization frameworks, which need information about class fields, without affecting their normal runtime semantics. However, the popular ecosystem examples that we've found of this form, using just metadata for fields, seem to depend on metadata generated by TypeScript types. From these examples, it seems that annotation syntax alone would not be a sufficient solution.
Some frameworks, including Angular, tend to use decorators which operate primarily by adding metadata. However, object literal annotations are not quite suitable for this usage, as they don't provide a way to be annotated in TypeScript to check types the way that functions do, they don't allow any processing of the metadata in code before it is saved, and because they don't provide a usable, stable identifier to be used for custom static analysis tools like tree shaking. For Angular, it may make more sense to use decorators which add metadata.
For these reasons, annotations are omitted from this proposal's "MVP" (minimum viable product) and considered as a possible follow-on proposal.
The @logged
decorator from earlier would Just Work(TM) on a function, with function decorators!
@logged
@{x: "y"}
function f() { }
f(); // prints logging information
f[Symbol.annotations][0].x // "y"
Function declarations with decorators or annotations are not hoisted. This is because it would be unintuitive to reorder the evaluation of the decorator or annotation expressions.
Instead, functions with decorators or annotations are defined only when their declaration is reached. If they are used before they are defined, a ReferenceError is thrown, like classes. This ReferenceError condition is sometimes referred to as a "temporal dead zone" (TDZ). The TDZ risks unfortunate situations when refactoring, but at least those situations lead to easy-to-debug errors rather than the wrong function being run.
Function decorator details:
- First parameter: the function being decorated (or, whatever the next inner decorator returned)
- Second parameter: a context object which just has
{ kind: 'function' }
- Return value: a new function, or undefined to keep the same function
The inner binding of a function expression inside itself is in TDZ until all the function decorators run.
A parameter decorator wraps the value of a function/method argument. It returns a function which does the wrapping.
function dec(_, context) {
assert(context.kind === "parameter");
return arg => arg - 1;
}
function f(@dec @{x: "y"} arg) { return arg * 2 ; }
f(5) // 8
f[Symbol.annotations].parameters[0].x // "y"
Functions with parameters that are decorated or annotated are treated similarly to decorated/annotated functions: they are not hoisted, and are in TDZ until their definition is executed.
Parameter decorator details:
- First parameter: undefined
- Second parameter: a context object which just has
{ kind: 'parameter' }
- Return value: a function which takes a parameter value and returns a new parameter value. The function is called with the
this
value that the surrounding function is called with.
This example can be desugared as
let decInstance = dec(undefined, {kind: "parameter"});
function f(arg_) {
const arg = decInstance(arg_);
return arg * 2 ;
}
f[Symbol.annotations] = []
f[Symbol.annotations].parameters = []
f[Symbol.annotations].parameters[0] = {x: "y"};
Variables declared with let
can be decorated, converting them into special getter/setter pairs that are invoked when the variable is read or written.
let @deprecated x = 1;
++x; // Shows deprecation warnings for read and write
let
decorators might be useful for systems using reactivity based on local variables, e.g. "hooks" systems.
let
decorator details:
- First parameter: A
{ get, set, value }
object (where the receiver is expected to be undefined, and wherevalue
is the RHS) - Second parameter: a context object
{ kind: "let" }
- Return value: A
{ get, set, value }
object, with potentially new behavior
This example can be desugared as:
let { get_x, set_x, value: _x } = depreciated({value: 1, get() { return _x; }, set(v) { _x = v; }}, {kind: "let"});
set_x(get_x()++);
Variables declared with const
can be decorated more simply--the decorator simply wraps the value being decorated when it's being defined.
function inc(x) { return x+1; }
const @inc x = 1; // 2
const
decorator details:
- First parameter: The value of the RHS
- Second parameter: a context object
{ kind: "const" }
- Return value: A new value for the variable
This could be desugared as follows:
const x = inc(1, {kind: "const"});
(This form isn't so useful by itself, but may become more important if future proposals share more information through the context object.)
- A decorated object literal works like a class decorator, but with
kind: "object"
. - A decorated method, getter or setter in an object literal works just like one in a class, replacing that method.
- A decorated object property works like a field decorator, but with
kind: "property"
, and it receives the initial value in thevalue
property of the input, and returns it in the output object, rather than returning an initializer function, since it only runs once (in this way, it is similar tolet
decorators).
Example:
const x = @decA {
@decB p: v,
@decC m() { }
};
@shicks While I can see how this would be useful, it's not clear to me how it would fit into JS runtime semantics. It sounds more like something to enforce at build time. Maybe this is better to keep a feature of type systems and linters? I'd like to understand TS's position here better, as I don't really see what we could do in the language.