JavaScript decorators have finally reached stage 3! Their latest version is already supported by Babel and will soon be supported by TypeScript.
This blog post covers the 2022-03 version (stage 3) of the ECMAScript proposal “Decorators” by Daniel Ehrenberg and Chris Garrett.
A decorator is a keyword that starts with an @
symbol and can be put in front of classes and class members (such as methods). For example, @trace
is a decorator:
class C {
@trace
toString() {
return 'C';
}
}
A decorator changes how the decorated construct works. In this case, every invocation of .toString()
will be “traced” (arguments and result will be logged to the console). We’ll see how @trace
is implemented later.
Decorators are mostly an object-oriented feature and popular in OOP frameworks and libraries such as Ember, Angular, Vue, web component frameworks and MobX.
There are two stakeholders when it comes to decorators:
This blog post is intended for library authors: We’ll learn how decorators work and use our knowledge to implement several of them.
(This section is optional. If you skip it, you can still understand the remaining content.)
Let’s start by looking at the history of decorators. Among others, two questions will be answered:
The following history describes:
This is a chronological account of relevant events:
2014-04-10: Decorators were proposed to TC39 by Yehuda Katz. The proposal advanced to stage 0.
2014-10-22 (ngEurope conference, Paris): The Angular team announced that Angular 2.0 was being written in AtScript and compiled to JavaScript (via Traceur) and Dart. Plans included basing AtScript on TypeScript while adding:
2015-01-28: Yehuda Katz and Jonathan Turner reported that Katz and the TypeScript team were exchanging ideas.
2015-03-05 (ng-conf, Salt Lake City): The Angular team and the TypeScript team announced that Angular would switch from AtScript to TypeScript and that TypeScript would adopt some of AtScript’s features (especially decorators).
2015-03-24: The decorator proposal reached stage 1. At that time, they had a repository on GitHub (created by Yehuda Katz) that was later moved to its current location.
2015-07-20: TypeScript 1.5 came out and supported stage 1 decorators behind the flag --experimentalDecorators
.
Several JavaScript projects (e.g. Angular and MobX) used this TypeScript feature which made it look like JavaScript already had decorators.
So far, TypeScript has not supported a newer version of the decorators API. A pull request by Ron Buckton provides support for stage 3 decorators and will likely ship in the release after v4.9.
2016-07-28: The proposal reached stage 2, after a presentation by Yehuda Katz and Brian Terlson.
2017-07-27: Daniel Ehrenberg held his first decorator presentation, after joining the proposal a few months earlier. He drove its evolution for several years.
Later, Chris Garrett joined the proposal and helped get it to stage 3, which happened on 2022-03-28. Decorator metadata was moved to a separate proposal that started at stage 2.
It took a long time to reach stage 3 because it was difficult to get all stakeholders to agree on an API. Concerns included interactions with other features (such as class members and private state) and performance.
Babel closely tracked the evolution of the decorator proposal, thanks to the efforts of Logan Smyth, Nicolò Ribaudo and others:
2015-03-31: Babel 5.0.0 supported stage 1 decorators.
2015-11-29 An external plugin by Logan Smyth brought support for stage 1 decorators to Babel 6.
2018-08-27 Babel 7.0.0 supported stage 2 decorators via the official @babel/plugin-proposal-decorators
.
The official plugin currently supports the following versions:
"legacy"
: stage 1 decorators"2018-09"
: stage 2 decorators"2021-12"
: an updated version of the original stage 2 decorators"2022-03"
: stage 3 decoratorsDecorators let us change how JavaScript constructs (such as classes and methods) work. Let’s revisit our previous example with the decorator @trace
:
class C {
@trace
toString() {
return 'C';
}
}
To implement @trace
, we only have to write a function (the exact implementation will be shown later):
function trace(decoratedMethod) {
// Returns a function that replaces `decoratedMethod`.
}
The class with the decorated method is roughly equivalent to the following code:
class C {
toString() {
return 'C';
}
}
C.prototype.toString = trace(C.prototype.toString);
In other words: A decorator is a function that we can apply to language constructs. We do so by putting @
plus its name in front of them.
Writing and using decorators is metaprogramming:
For more information on metaprogramming, see section “Programming versus metaprogramming” in “Deep JavaScript”.
Before we explore examples of decorator functions, I’d like to take a look at their TypeScript type signature:
type Decorator = (
value: DecoratedValue, // only fields differ
context: {
kind: string;
name: string | symbol;
addInitializer(initializer: () => void): void;
// Don’t always exist:
static: boolean;
private: boolean;
access: {get: () => unknown, set: (value: unknown) => void};
}
) => void | ReplacementValue; // only fields differ
That is, a decorator is a function. Its parameters are:
value
that the decorator is applied to.context
with:
value
(.static
, .private
).access
, .addInitializer
) with metaprogramming functionalityProperty .kind
tells the decorator which kind of JavaScript construct it is applied to. We can use the same function for multiple constructs.
Currently, decorators can be applied to classes, methods, getters, setters, fields, and auto-accessors (a new class member that is explained later). The values of .kind
reflect that:
'class'
'method'
'getter'
'setter'
'accessor'
'field'
This is the exact type of Decorator
:
type Decorator =
| ClassDecorator
| ClassMethodDecorator
| ClassGetterDecorator
| ClassSetterDecorator
| ClassAutoAccessorDecorator
| ClassFieldDecorator
;
We’ll soon encounter each of these kinds of decorators and its type signature – where only these parts change:
value
context
Each decorator has up to four abilities:
It can change the decorated entity by changing the parameter value
.
It can replace the decorated entity by returning a compatible value:
undefined
– either explicitly or implicitly, by not returning anything.Exposing access to the decorated entity to others. context.access
enables it to do that, via its methods .get()
and .set()
.
Processing the decorated entity and its container (if it has one), after both exist: That functionality is provided by context.addInitializer
. It lets the decorator register an initializer – a callback that is invoked when everything is ready (more details are explained later).
The next subsections demonstrate these abilities. We initially won’t use context.kind
to check which kind of construct a decorator is applied to. We will do that later, though.
In the following example, the decorator @replaceMethod
replaces method .hello()
(line B) with a function that it returns (line A).
function replaceMethod() {
return function () { // (A)
return `How are you, ${this.name}?`;
}
}
class Person {
constructor(name) {
this.name = name;
}
@replaceMethod
hello() { // (B)
return `Hi ${this.name}!`;
}
}
const robin = new Person('Robin');
assert.equal(
robin.hello(), 'How are you, Robin?'
);
In the next example, the decorator @exposeAccess
stores an object in the variable acc
that lets us access property .green
of the instances of Color
.
let acc;
function exposeAccess(_value, {access}) {
acc = access;
}
class Color {
@exposeAccess
name = 'green'
}
const green = new Color();
assert.equal(
green.name, 'green'
);
// Using `acc` to get and set `green.name`
assert.equal(
acc.get.call(green), 'green'
);
acc.set.call(green, 'red');
assert.equal(
green.name, 'red'
);
In the following code, we use the decorator @collect
to store the keys of decorated methods in the instance property .collectedMethodKeys
:
function collect(_value, {name, addInitializer}) {
addInitializer(function () { // (A)
if (!this.collectedMethodKeys) {
this.collectedMethodKeys = new Set();
}
this.collectedMethodKeys.add(name);
});
}
class C {
@collect
toString() {}
@collect
[Symbol.iterator]() {}
}
const inst = new C();
assert.deepEqual(
inst.collectedMethodKeys,
new Set(['toString', Symbol.iterator])
);
The initializer function added by the decorator in line A must be an ordinary function because access to the implicit parameter this
is needed. Arrow functions don’t provide this access – their this
is statically scoped (like any normal variable).
Type signature:
Kind of decorator | (input) => output |
.access |
---|---|---|
Class | (func) => func2 |
– |
Method | (func) => func2 |
{get} |
Getter | (func) => func2 |
{get} |
Setter | (func) => func2 |
{set} |
Auto-accessor | ({get,set}) => {get,set,init} |
{get,set} |
Field | () => (initValue)=>initValue2 |
{get,set} |
Value of this
in functions:
this is → |
undefined |
Class | Instance |
---|---|---|---|
Decorator function | ✔ | ||
Static initializer | ✔ | ||
Non-static initializer | ✔ | ||
Static field decorator result | ✔ | ||
Non-static field decorator result | ✔ |
(This section is optional. If you skip it, you can still understand the remaining content.)
#
). Square brackets []
are not allowed.@(«expr»)
Wherever decorators are allowed, we can use more than one of them. The following code demonstrates decorator syntax:
// Five decorators for MyClass
@myFunc
@myFuncFactory('arg1', 'arg2')
@libraryModule.prop
@someObj.method(123)
@(wrap(dict['prop'])) // arbitrary expression
class MyClass {}
Evaluation: The expressions after the @
symbols are evaluated during the execution of the class definition, along with computed property keys and static fields (see code below). The results must be functions. They are stored in temporary locations (think local variables), to be invoked later.
Invocation: The decorator functions are called later during the execution of a class definition, after methods have been evaluated but before constructor and prototype have been assembled. Once again the results are stored in temporary locations.
Application: After all decorator functions were invoked, their results are used, which can affect constructor and prototype. Class decorators are applied after all method and field decorators.
The following code illustrates in which order decorator expressions, computed property keys and field initializers are evaluated:
function decorate(str) {
console.log(`EVALUATE @decorate(): ${str}`);
return () => console.log(`APPLY @decorate(): ${str}`); // (A)
}
function log(str) {
console.log(str);
return str;
}
@decorate('class')
class TheClass {
@decorate('static field')
static staticField = log('static field value');
@decorate('prototype method')
[log('computed key')]() {}
@decorate('instance field')
instanceField = log('instance field value');
// This initializer only runs if we instantiate the class
}
// Output:
// EVALUATE @decorate(): class
// EVALUATE @decorate(): static field
// EVALUATE @decorate(): prototype method
// computed key
// EVALUATE @decorate(): instance field
// APPLY @decorate(): prototype method
// APPLY @decorate(): static field
// APPLY @decorate(): instance field
// APPLY @decorate(): class
// static field value
Function decorate
is invoked whenever the expression decorate()
after the @
symbol is evaluated. In line A, it returns the actual decorator function, which is applied later.
When a decorator initializer runs, depends on the kind of decorator:
Class decorator initializers run after the class is fully defined and all static fields were initialized.
The initializers of non-static class element decorators run during instantiation, before instance fields are initialized.
The initializers of static class element decorators run during class definition, before static fields are defined but after other all other class elements were defined.
Why is that? For non-static initializers, we have five options – they can run:
super
super
, before field initializationWhy was #2 chosen?
#1 was rejected because decorator initializers must be able to access this
, which isn’t possible before super
runs.
#3 was rejected because running all decorator initializers at the same time is simpler than ensuring that they are properly interleaved.
#4 was rejected because running decorator initializers before fields ensures that fields don’t see partially initialized methods. For example, if there are @bind
decorators, then field initializers can rely on the decorated methods being bound.
#5 was rejected because it would allow superclasses to interfere with subclasses, which would break the rule that superclasses should not be aware of their subclasses.
The following code demonstrates in which order Babel currently invokes decorator initializers. Note that Babel does not yet support initializers for class field decorators (which was a recent change to the decorators API).
// We wait until after instantiation before we log steps,
// so that we can compare the value of `this` with the instance.
const steps = [];
function push(msg, _this) {
steps.push({msg, _this});
}
function pushStr(str) {
steps.push(str);
}
function init(_value, {name, addInitializer}) {
pushStr(`@init ${name}`);
if (addInitializer) {
addInitializer(function () {
push(`DECORATOR INITIALIZER ${name}`, this);
});
}
}
@init class TheClass {
//--- Static ---
static {
pushStr('static block');
}
@init static staticMethod() {}
@init static accessor staticAcc = pushStr('staticAcc');
@init static staticField = pushStr('staticField');
//--- Non-static ---
@init prototypeMethod() {}
@init accessor instanceAcc = pushStr('instanceAcc');
@init instanceField = pushStr('instanceField');
constructor() {
pushStr('constructor');
}
}
pushStr('===== Instantiation =====');
const inst = new TheClass();
for (const step of steps) {
if (typeof step === 'string') {
console.log(step);
continue;
}
let thisDesc = '???';
if (step._this === TheClass) {
thisDesc = TheClass.name;
} else if (step._this === inst) {
thisDesc = 'inst';
} else if (step._this === undefined) {
thisDesc = 'undefined';
}
console.log(`${step.msg} (this===${thisDesc})`);
}
// Output:
// @init staticMethod
// @init staticAcc
// @init prototypeMethod
// @init instanceAcc
// @init staticField
// @init instanceField
// @init TheClass
// DECORATOR INITIALIZER staticMethod (this===TheClass)
// DECORATOR INITIALIZER staticAcc (this===TheClass)
// static block
// staticAcc
// staticField
// DECORATOR INITIALIZER TheClass (this===TheClass)
// ===== Instantiation =====
// DECORATOR INITIALIZER prototypeMethod (this===inst)
// DECORATOR INITIALIZER instanceAcc (this===inst)
// instanceAcc
// instanceField
// constructor
Sometimes decorators collect data. Let’s explore how they can make this data available to other parties.
The simplest solution is to store data in a location in a surrounding scope. For example, the decorator @collect
collects classes and stores them in the Set classes
(line A):
const classes = new Set(); // (A)
function collect(value, {kind, addInitializer}) {
if (kind === 'class') {
classes.add(value);
}
}
@collect
class A {}
@collect
class B {}
@collect
class C {}
assert.deepEqual(
classes, new Set([A, B, C])
);
The downside of this approach is that it doesn’t work if a decorator comes from another module.
A more sophisticated approach is to use a factory function createClassCollector()
that returns:
collect
classes
, to which the decorator will add the classes it collectsfunction createClassCollector() {
const classes = new Set();
function collect(value, {kind, addInitializer}) {
if (kind === 'class') {
classes.add(value);
}
}
return {
classes,
collect,
};
}
const {classes, collect} = createClassCollector();
@collect
class A {}
@collect
class B {}
@collect
class C {}
assert.deepEqual(
classes, new Set([A, B, C])
);
Instead of a factory function, we can also use a class. It has two members:
.classes
, a Set with the collected classes.install
, a class decoratorclass ClassCollector {
classes = new Set();
install = (value, {kind}) => { // (A)
if (kind === 'class') {
this.classes.add(value); // (B)
}
};
}
const collector = new ClassCollector();
@collector.install
class A {}
@collector.install
class B {}
@collector.install
class C {}
assert.deepEqual(
collector.classes, new Set([A, B, C])
);
We implemented .install
by assigning an arrow function to a public instance field (line A). Instance field initializers run in scopes where this
refers to the current instance. That is also the outer scope of the arrow function and explains what value this
has in line B.
We could also implement .install
via a getter, but then we’d have to return a new function whenever .install
is read.
Class decorators have the following type signature:
type ClassDecorator = (
value: Function,
context: {
kind: 'class';
name: string | undefined;
addInitializer(initializer: () => void): void;
}
) => Function | void;
Abilities of a class decorator:
value
.context.access
because classes are not members of other language constructs (whereas, e.g., methods are members of classes).In the next example, we use a decorator to collect all instances of a decorated class:
class InstanceCollector {
instances = new Set();
install = (value, {kind}) => {
if (kind === 'class') {
const _this = this;
return function (...args) { // (A)
const inst = new value(...args); // (B)
_this.instances.add(inst);
return inst;
};
}
};
}
const collector = new InstanceCollector();
@collector.install
class MyClass {}
const inst1 = new MyClass();
const inst2 = new MyClass();
const inst3 = new MyClass();
assert.deepEqual(
collector.instances, new Set([inst1, inst2, inst3])
);
The only way in which we can collect all instances of a given class via a decorator is by wrapping that class. The decorator in the field .install
does that by returning a function (line A) that new-calls the decorated value
(line B) and collects and returns the result.
Note that we can’t return an arrow function in line A, because arrow functions can’t be new-called.
One downside of this approach is that it breaks instanceof
:
assert.equal(
inst1 instanceof MyClass,
false
);
The next subsection explains how we can fix that.
instanceof
works In this section, we use the simple decorator @countInstances
to show how we can support instanceof
for wrapped classes.
instanceof
via .prototype
One way of enabling instanceof
is to set the .prototype
of the wrapper function to the .prototype
of the wrapped value
(line A):
function countInstances(value) {
const _this = this;
let instanceCount = 0;
// The wrapper must be new-callable
const wrapper = function (...args) {
instanceCount++;
const instance = new value(...args);
// Change the instance
instance.count = instanceCount;
return instance;
};
wrapper.prototype = value.prototype; // (A)
return wrapper;
}
@countInstances
class MyClass {}
const inst1 = new MyClass();
assert.ok(inst1 instanceof MyClass);
assert.equal(inst1.count, 1);
const inst2 = new MyClass();
assert.ok(inst2 instanceof MyClass);
assert.equal(inst2.count, 2);
Why does that work? Because the following expressions are equivalent:
inst instanceof C
C.prototype.isPrototypeOf(inst)
For more information on instanceof
, see “JavaScript for impatient programmers”.
instanceof
via Symbol.hasInstance
Another option for enabling instanceof
is to give the wrapper function a method whose key is Symbol.hasInstance
(line A):
function countInstances(value) {
const _this = this;
let instanceCount = 0;
// The wrapper must be new-callable
const wrapper = function (...args) {
instanceCount++;
const instance = new value(...args);
// Change the instance
instance.count = instanceCount;
return instance;
};
// Property is ready-only, so we can’t use assignment
Object.defineProperty( // (A)
wrapper, Symbol.hasInstance,
{
value: function (x) {
return x instanceof value;
}
}
);
return wrapper;
}
@countInstances
class MyClass {}
const inst1 = new MyClass();
assert.ok(inst1 instanceof MyClass);
assert.equal(inst1.count, 1);
const inst2 = new MyClass();
assert.ok(inst2 instanceof MyClass);
assert.equal(inst2.count, 2);
“JavaScript for impatient programmers” has more information on Symbol.hasInstance
.
instanceof
via subclassing We can also enable instanceof
by returning a subclass of value
(line A):
function countInstances(value) {
const _this = this;
let instanceCount = 0;
// The wrapper must be new-callable
return class extends value { // (A)
constructor(...args) {
super(...args);
instanceCount++;
// Change the instance
this.count = instanceCount;
}
};
}
@countInstances
class MyClass {}
const inst1 = new MyClass();
assert.ok(inst1 instanceof MyClass);
assert.equal(inst1.count, 1);
const inst2 = new MyClass();
assert.ok(inst2 instanceof MyClass);
assert.equal(inst2.count, 2);
The decorator class @freeze
freezes all instances produced by the classes it decorates:
function freeze (value, {kind}) {
if (kind === 'class') {
return function (...args) {
const inst = new value(...args);
return Object.freeze(inst);
}
}
}
@freeze
class Color {
constructor(name) {
this.name = name;
}
}
const red = new Color('red');
assert.throws(
() => red.name = 'green',
/^TypeError: Cannot assign to read only property 'name'/
);
This decorator has downsides:
instanceof
. We have already seen how to fix this.value
.this
is immutable. There is no way to avoid this downside.The last downside could be avoided by giving class decorators access to the instances of the decorated classes after all constructors were executed.
This would change how inheritance works because a superclass could now change properties that were added by subclasses. Therefore, it’s not sure if such a mechanism is in the cards.
Classes decorated by @functionCallable
can be invoked by function calls instead of the new
operator:
function functionCallable(value, {kind}) {
if (kind === 'class') {
return function (...args) {
if (new.target !== undefined) {
throw new TypeError('This function can’t be new-invoked');
}
return new value(...args);
}
}
}
@functionCallable
class Person {
constructor(name) {
this.name = name;
}
}
const robin = Person('Robin');
assert.equal(
robin.name, 'Robin'
);
Class method decorators have the following type signature:
type ClassMethodDecorator = (
value: Function,
context: {
kind: 'method';
name: string | symbol;
static: boolean;
private: boolean;
access: { get: () => unknown };
addInitializer(initializer: () => void): void;
}
) => Function | void;
Abilities of a method decorator:
value
.context.access
only supports getting the value of its property, not setting it.Constructors can’t be decorated: They look like methods, but they aren’t really methods.
The decorator @trace
wraps methods so that their invocations and results are logged to the console:
function trace(value, {kind, name}) {
if (kind === 'method') {
return function (...args) {
console.log(`CALL ${name}: ${JSON.stringify(args)}`);
const result = value.apply(this, args);
console.log('=> ' + JSON.stringify(result));
return result;
};
}
}
class StringBuilder {
#str = '';
@trace
add(str) {
this.#str += str;
}
@trace
toString() {
return this.#str;
}
}
const sb = new StringBuilder();
sb.add('Home');
sb.add('page');
assert.equal(
sb.toString(), 'Homepage'
);
// Output:
// CALL add: ["Home"]
// => undefined
// CALL add: ["page"]
// => undefined
// CALL toString: []
// => "Homepage"
Normally, extracting methods (line A) means that we can’t function-call them because that sets this
to undefined
:
class Color1 {
#name;
constructor(name) {
this.#name = name;
}
toString() {
return `Color(${this.#name})`;
}
}
const green1 = new Color1('green');
const toString1 = green1.toString; // (A)
assert.throws(
() => toString1(),
/^TypeError: Cannot read properties of undefined/
);
We can fix that via the decorator @bind
:
function bind(value, {kind, name, addInitializer}) {
if (kind === 'method') {
addInitializer(function () { // (B)
this[name] = value.bind(this); // (C)
});
}
}
class Color2 {
#name;
constructor(name) {
this.#name = name;
}
@bind
toString() {
return `Color(${this.#name})`;
}
}
const green2 = new Color2('green');
const toString2 = green2.toString;
assert.equal(
toString2(), 'Color(green)'
);
// The own property green2.toString is different
// from Color2.prototype.toString
assert.ok(Object.hasOwn(green2, 'toString'));
assert.notEqual(
green2.toString,
Color2.prototype.toString
);
Per decorated method, the initializer registered in line B is invoked whenever an instance is created and adds an own property whose value is a function with a fixed this
(line C).
The library core-decorators
has a decorator that lets us apply functions to methods. That enables us to use helper functions such as Lodash’s memoize()
. The following code shows an implementation @applyFunction
of such a decorator:
import { memoize } from 'lodash-es';
function applyFunction(functionFactory) {
return (value, {kind}) => { // decorator function
if (kind === 'method') {
return functionFactory(value);
}
};
}
let invocationCount = 0;
class Task {
@applyFunction(memoize)
expensiveOperation(str) {
invocationCount++;
// Expensive processing of `str` 😀
return str + str;
}
}
const task = new Task();
assert.equal(
task.expensiveOperation('abc'),
'abcabc'
);
assert.equal(
task.expensiveOperation('abc'),
'abcabc'
);
assert.equal(
invocationCount, 1
);
These are the type signatures of getter decorators and setter decorators:
type ClassGetterDecorator = (
value: Function,
context: {
kind: 'getter';
name: string | symbol;
static: boolean;
private: boolean;
access: { get: () => unknown };
addInitializer(initializer: () => void): void;
}
) => Function | void;
type ClassSetterDecorator = (
value: Function,
context: {
kind: 'setter';
name: string | symbol;
static: boolean;
private: boolean;
access: { set: (value: unknown) => void };
addInitializer(initializer: () => void): void;
}
) => Function | void;
Getter decorators and setter decorators have similar abilities to method decorators.
To implement a property whose value is computed lazily (on demand), we use two techniques:
We implement the property via a getter. That way, the code that computes its value, is only executed if the property is read.
The decorator @lazy
wraps the original getter: When the wrapper is invoked for the first time, it invokes the getter and creates an own data property whose value is the result. From now on, the own property overrides the inherited getter whenever someone reads the property.
class C {
@lazy
get value() {
console.log('COMPUTING');
return 'Result of computation';
}
}
function lazy(value, {kind, name, addInitializer}) {
if (kind === 'getter') {
return function () {
const result = value.call(this);
Object.defineProperty( // (A)
this, name,
{
value: result,
writable: false,
}
);
return result;
};
}
}
console.log('1 new C()');
const inst = new C();
console.log('2 inst.value');
assert.equal(inst.value, 'Result of computation');
console.log('3 inst.value');
assert.equal(inst.value, 'Result of computation');
console.log('4 end');
// Output:
// 1 new C()
// 2 inst.value
// COMPUTING
// 3 inst.value
// 4 end
Note that property .[name]
is immutable (because there is only a getter), which is why we have to define the property (line A) and can’t use assignment.
Class field decorators have the following type signature:
type ClassFieldDecorator = (
value: undefined,
context: {
kind: 'field';
name: string | symbol;
static: boolean;
private: boolean;
access: { get: () => unknown, set: (value: unknown) => void };
addInitializer(initializer: () => void): void;
}
) => (initialValue: unknown) => unknown | void;
Abilities of a field decorator:
It cannot change or replace its field. If we need that functionality, we have to use an auto-accessor (what that is, is described later).
It can change the value with which “its” field is initialized, by returning a function that receives the original initialization value and returns a new initialization value.
this
refers to the current instance.It can register initializers. That is a recent change (post-2022-03) of the decorators API and wasn’t possible before.
context.access
.The decorator @twice
doubles the original initialization value of a field by returning a function that performs this change:
function twice() {
return initialValue => initialValue * 2;
}
class C {
@twice
field = 3;
}
const inst = new C();
assert.equal(
inst.field, 6
);
The decorator @readOnly
makes a field immutable. It waits until the field was completely set up (either via an assignment or via the constructor) before it does so.
const readOnlyFieldKeys = Symbol('readOnlyFieldKeys');
@readOnly
class Color {
@readOnly
name;
constructor(name) {
this.name = name;
}
}
const blue = new Color('blue');
assert.equal(blue.name, 'blue');
assert.throws(
() => blue.name = 'brown',
/^TypeError: Cannot assign to read only property 'name'/
);
function readOnly(value, {kind, name}) {
if (kind === 'field') { // (A)
return function () {
if (!this[readOnlyFieldKeys]) {
this[readOnlyFieldKeys] = [];
}
this[readOnlyFieldKeys].push(name);
};
}
if (kind === 'class') { // (B)
return function (...args) {
const inst = new value(...args);
for (const key of inst[readOnlyFieldKeys]) {
Object.defineProperty(inst, key, {writable: false});
}
return inst;
}
}
}
We need two steps to implement the functionality of @readOnly
(which is why the class is also decorated):
Similarly to making instances immutable, this decorator breaks instanceof
. The same workaround can be used here, too.
We’ll later see a version @readOnly
that works with auto-accessors instead of fields. That implementation does not require the class to be decorated.
Dependency injection is motivated by the following observation: If we provide the constructor of a class with its dependencies (vs. the constructor setting them up itself), then it’s easier to adapt the dependencies to different environments, including testing.
This is an inversion of control: The constructor does not do its own setup, we do it for it. Approaches for doing dependency injection:
The following code is a simple implementation of approach #3:
const {registry, inject} = createRegistry();
class Logger {
log(str) {
console.log(str);
}
}
class Main {
@inject logger;
run() {
this.logger.log('Hello!');
}
}
registry.register('logger', Logger);
new Main().run();
// Output:
// Hello!
This is how createRegistry()
is implemented:
function createRegistry() {
const nameToClass = new Map();
const nameToInstance = new Map();
const registry = {
register(name, componentClass) {
nameToClass.set(name, componentClass);
},
getInstance(name) {
if (nameToInstance.has(name)) {
return nameToInstance.get(name);
}
const componentClass = nameToClass.get(name);
if (componentClass === undefined) {
throw new Error('Unknown component name: ' + name);
}
const inst = new componentClass();
nameToInstance.set(name, inst);
return inst;
},
};
function inject (_value, {kind, name}) {
if (kind === 'field') {
return () => registry.getInstance(name);
}
}
return {registry, inject};
}
We can change the visibility of some class members by making them private. That prevents them from being accessed publicly. There are more useful kinds of visibility, though. For example, friend visibility lets a group of friends (functions, other classes, etc.) access the member.
There are many ways in which friends can be specified. In the following example, everyone who has access to friendName
, is a friend of classWithSecret.#name
. The idea is that a module contains classes and functions that collaborate and that there is some instance data that only the collaborators should be able see.
const friendName = new Friend();
class ClassWithSecret {
@friendName.install #name = 'Rumpelstiltskin';
getName() {
return this.#name;
}
}
// Everyone who has access to `secret`, can access inst.#name
const inst = new ClassWithSecret();
assert.equal(
friendName.get(inst), 'Rumpelstiltskin'
);
friendName.set(inst, 'Joe');
assert.equal(
inst.getName(), 'Joe'
);
This is how class Friend
is implemented:
class Friend {
#access = undefined;
#getAccessOrThrow() {
if (this.#access === undefined) {
throw new Error('The friend decorator wasn’t used yet');
}
return this.#access;
}
// An instance property whose value is a function whose `this`
// is fixed (bound to the instance).
install = (_value, {kind, access}) => {
if (kind === 'field') {
if (this.#access) {
throw new Error('This decorator can only be used once');
}
this.#access = access;
}
}
get(inst) {
return this.#getAccessOrThrow().get.call(inst);
}
set(inst, value) {
return this.#getAccessOrThrow().set.call(inst, value);
}
}
There are many ways to implement enums. An OOP-style approach is to use a class and static properties (more information on this approach):
class Color {
static red = new Color('red');
static green = new Color('green');
static blue = new Color('blue');
constructor(enumKey) {
this.enumKey = enumKey;
}
toString() {
return `Color(${this.enumKey})`;
}
}
assert.equal(
Color.green.toString(),
'Color(green)'
);
We can use a decorator to automatically:
That looks as follows:
function enumEntry(value, {kind, name}) {
if (kind === 'field') {
return function (initialValue) {
if (!Object.hasOwn(this, 'enumFields')) {
this.enumFields = new Map();
}
this.enumFields.set(name, initialValue);
initialValue.enumKey = name;
return initialValue;
};
}
}
class Color {
@enumEntry static red = new Color();
@enumEntry static green = new Color();
@enumEntry static blue = new Color();
toString() {
return `Color(${this.enumKey})`;
}
}
assert.equal(
Color.green.toString(),
'Color(green)'
);
assert.deepEqual(
Color.enumFields,
new Map([
['red', Color.red],
['green', Color.green],
['blue', Color.blue],
])
);
The decorators proposal introduces a new language feature: auto-accessors. An auto-accessor is created by putting the keyword accessor
before a class field. It is used like a field but implemented differently at runtime. That helps decorators as we’ll see soon. This is what auto-accessors look like:
class C {
static accessor myField1;
static accessor #myField2;
accessor myField3;
accessor #myField4;
}
How do fields and auto-accessors differ?
Consider the following class:
class C {
accessor str = 'abc';
}
const inst = new C();
assert.equal(
inst.str, 'abc'
);
inst.str = 'def';
assert.equal(
inst.str, 'def'
);
Internally, it looks like this:
class C {
#str = 'abc';
get str() {
return this.#str;
}
set str(value) {
this.#str = value;
}
}
The following code shows where the getters and setters of auto-accessors are located:
class C {
static accessor myField1;
static accessor #myField2;
accessor myField3;
accessor #myField4;
static {
// Static getter and setter
assert.ok(
Object.hasOwn(C, 'myField1'), 'myField1'
);
// Static getter and setter
assert.ok(
#myField2 in C, '#myField2'
);
// Prototype getter and setter
assert.ok(
Object.hasOwn(C.prototype, 'myField3'), 'myField3'
);
// Private getter and setter
// (stored in instances, but shared between instances)
assert.ok(
#myField4 in new C(), '#myField4'
);
}
}
For more information on why the slots of private getters, private setters and private methods are stored in instances, see section “Private methods and accessors” in “JavaScript for impatient programmers”.
Auto-accessors are needed by decorators:
Therefore, we have to use auto-accessors instead of fields whenever a decorator needs more control than it has with fields.
Class auto-accessor decorators have the following type signature:
type ClassAutoAccessorDecorator = (
value: {
get: () => unknown;
set: (value: unknown) => void;
},
context: {
kind: 'accessor';
name: string | symbol;
static: boolean;
private: boolean;
access: { get: () => unknown, set: (value: unknown) => void };
addInitializer(initializer: () => void): void;
}
) => {
get?: () => unknown;
set?: (value: unknown) => void;
init?: (initialValue: unknown) => unknown;
} | void;
Abilities of an auto-accessor decorator:
value
.
context.access
provides the same functionality..get()
and/or .set()
..init()
.We have already implemented a decorator @readOnly
for fields. Let’s do the same for auto-accessors:
const UNINITIALIZED = Symbol('UNINITIALIZED');
function readOnly({get,set}, {name, kind}) {
if (kind === 'accessor') {
return {
init() {
return UNINITIALIZED;
},
get() {
const value = get.call(this);
if (value === UNINITIALIZED) {
throw new TypeError(
`Accessor ${name} hasn’t been initialized yet`
);
}
return value;
},
set(newValue) {
const oldValue = get.call(this);
if (oldValue !== UNINITIALIZED) {
throw new TypeError(
`Accessor ${name} can only be set once`
);
}
set.call(this, newValue);
},
};
}
}
class Color {
@readOnly
accessor name;
constructor(name) {
this.name = name;
}
}
const blue = new Color('blue');
assert.equal(blue.name, 'blue');
assert.throws(
() => blue.name = 'yellow',
/^TypeError: Accessor name can only be set once$/
);
const orange = new Color('orange');
assert.equal(orange.name, 'orange');
Compared to the field version, this decorator has one considerable advantage: It does not need to wrap the class to ensure that the decorated constructs become read-only.
The current proposal focuses on classes as a starting point. Decorators for function expressions were proposed. However, there hasn’t been much progress since then and there is no proposal for function declarations.
On the other hand, functions are relatively easy to decorate “manually”:
const decoratedFunc = decorator((x, y) => {});
This looks even better with the proposed pipeline operator:
const decoratedFunc = (x, y) => {} |> decorator(%);
The following ECMAScript proposals provide more decorator-related features:
@babel/plugin-proposal-decorators
.
These are libraries with decorators. They currently only support stage 1 decorators but can serve as inspirations for what’s possible:
Chapter “Callable values” [ordinary functions, arrow functions, classes, methods] in “JavaScript for impatient programmers”
Chapter “Classes” in “JavaScript for impatient programmers”