In this article, we review decorators in JavaScript, and we will also take a look at some examples and quirks that you may come across if you start experimenting with decorators today.
The popularity (or rather, the rise) of decorators is mostly thanks to Angular 2+ and TypeScript since they form a core part of the front-end framework. However, what are decorators? Why are they useful? Why would it be useful to have them in JavaScript? Let's try to answer these questions.
At the time of writing this article, the decorators proposal is at Stage 2 as per the TC39 process. This means that if things go well, soon enough decorators will also be part of JavaScript language, however also note that the proposal may change and some statements found in this article may no longer stand correct.
Let's start by taking a look at a simple decorator that - providing the fact that you are an Angular developer or have seen some Angular code before - should look very familiar:
//some.component.ts
@Component({
selector: 'app-my-list',
templateUrl: './some.component.html',
})
export class SomeComponent implements OnInit {
// ...
}
In the above code, the class SomeComponent
is given additional functionality by applying a decorator to it (or in other words, we are decorating an existing class with some additional functionality). The decorator here is @Component({})
and we can think of it as giving the class some additional functionality by wrapping the code found in the decorator itself. This is the same concept that is defined by functional compositions or higher-order functions (which is a concept advocated heavily by React).
Simply put, a decorator is just a function capable of extending the capabilities of the element to which it was attached to.
We can utilise higher-order functions in JavaScript today, without a problem in a rather simple way:
function hello(user) {
console.log(`Hi ${user}`);
}
function punctuate(fn) {
return function (user) {
fn(`${user}! Looking great today ;)`);
};
}
const greeting = punctuate(hello);
greeting('John'); // Hi John! Looking great today ;)
Using decorators in TypeScript is possible via the
--experimentalDecorators
command line flag fortsc
or by adding the following configuration totsconfig.json
:{ "compilerOptions": { "target": "ES5", "experimentalDecorators": true } }
The decorator pattern is an object-oriented programming pattern where individual classes can be dynamically given additional functionality, without effecting instances of the same class. Now, translating this to the JavaScript developer, it means that even though we can use high-order functions today in the language, we can't apply the same principals (the principals of HOF) to a class written using ES2015.
There's one limitation that we need to be aware of as well with regards to decorators and JavaScript, namely that decorators can be exclusively used on classes and class members.
As mentioned earlier, decorators cannot be used directly in JavaScript since they are only at a proposal stage. This means that we have to resort to using Babel to transpile code that uses decorators that are currently understood by the browser or Node.js. The babel plugin @babel/plugin-proposal-decorators allows us to achieve this.
Note that at the time of writing this article Babel supports the old version of the proposal (Stage 2 - November 2018), but the proposal has since progressed then. It is recommended that the plugin is used with the
legacy: true
option for the time being until the latest changes in the proposal are implemented by Babel.
Let's go ahead and create a babel configuration via the .babelrc
file with the following content:
{
"plugins": [
[
"@babel/plugin-proposal-decorators",
{
"legacy": true
}
]
]
}
For the sake of simplicity, I am using Node.js to execute my code, and I have set up the following npm script in my package.json
file:
"scripts": {
"babel": "node_modules/.bin/babel decorator.js --out-file decorator.es5.js",
"start": "node decorator.es5.js"
},
This allows the execution of npm run babel && npm start
from the terminal.
Let's take a look at how we can add a decorator to a class member - in this case, to a class member function:
class Greeter {
constructor(name) {
this.name = name;
}
@punctuate('!')
hello() {
return `Hi ${this.name}`;
}
}
function punctuate(value) {
return function (target, key, descriptor) {
descriptor.value = function hello() {
return `Hi ${this.name}${value}. You are awesome ;)`;
};
};
}
const greeting = new Greeter('John');
greeting.hello(); // Hi John!. You are awesome ;)
As you can see, the decorator is just a function (punctuate()
), and we can decide to pass parameters to it (a decorator without any parameters is also valid of course). In this particular example, we overwrite what the hello()
function is doing, and instead of just returning a simple statement, we return two sentences. Go ahead and change the @punctuate('!')
decorator and replace the !
with a ?
and observe what happens.
Let's dig a little bit deeper and see what the parameters contains in our decorator function:
function punctuate(value) {
return function (target, key, descriptor) {
console.log('target', target);
console.log('key', key);
console.log('descriptor', descriptor);
};
}
// returns ==>
target Greeter {}
key hello
descriptor { value: [Function: hello],
writable: true,
enumerable: false,
configurable: true }
As we can see from the above the target is the class that we are working on, the key is the class member function (this is also verifying what we have stated earlier, that a decorator works on a given class method), and then we have the descriptor, which is the object that describes the data or the accessor. You may have seen a descriptor object before when using Object.defineProperty()
in JavaScript:
Object.defineProperty({}, 'key', {
value: 'some value',
configurable: false,
enumerable: false,
writeable: false,
});
Since we have access to all these property values, we can make our property read-only, by changing the writeable
property from true
to false
- this is going to mean that just by using a decorator we can make class members read-only:
class Greeter {
constructor(name) {
this.name = name;
}
@readonly()
hello() {
return `Hi ${this.name}`;
}
}
function readonly() {
return function (target, key, descriptor) {
descriptor.writable = false;
return descriptor;
};
}
const greeting = new Greeter('John');
greeting.hello = function () {
return 'Never gets called! :(';
};
console.log(greeting.hello()); // Hi John
Notice that the new hello function never gets called - as a test, remove the
@readonly
decorator and see how the code behaves then.
We could also use this technique to make class members (non-methods) read-only.
Note that for the below example to work we need to install
@babel/plugin-proposal-class-properties
and add it to.babelrc
with the optionloose: true
. This is needed because class properties are not part of the JavaScript language yet.
class Greeter {
@readonly name = 'John';
hello() {
return `Hi ${this.name}`;
}
}
function readonly(target, key, descriptor) {
descriptor.writable = false;
return descriptor;
}
const greeting = new Greeter();
greeting.name = 'Jack';
greeting.hello(); // Hi John
As you can see, we can't overwrite the name
property because we have decorated it to be read-only.
Just as a comparison take a look at how the
target
object would be different if using thelegacy: false
option in.babelrc
:
function punctuate(value) {
return function (target) {
console.log(target);
};
}
// returns ==>
Object [Descriptor] {
kind: 'method',
key: 'hello',
placement: 'prototype',
descriptor:
{ value: [Function: hello],
writable: true,
configurable: true,
enumerable: false } }
The information returned is the same but this time an entire descriptor object is returned.
So far, we saw how to decorate class methods, but it is also possible to decorate an entire class. The main difference is that while a class member decorator is only valid for the proceeding method or property, the class decorator is applied to the entire class. Also, just like how the class member decorators, these also accept parameters.
Let's see an example:
@isEmployee
class Greeter {
constructor(name) {
this.name = name;
}
hello() {
return `Hi ${this.name}`;
}
}
function isEmployee(target) {
return class extends target {
constructor(...args) {
super(...args);
this.isEmployee = true;
}
};
}
const greeting = new Greeter('John');
greeting; // Greeter { name: 'John', isEmployee: true }
As seen above, we were able to add a new property to our class, using the annotation.
How would the above look using the legacy: false
option? It certainly involves some more coding, but the result is going to be the same:
@isEmployee(false)
class Greeter {
name = 'John';
hello() {
return `Hi ${this.name}`;
}
}
function isEmployee(value) {
return function (descriptor) {
const { kind, elements } = descriptor;
const newElements = elements.concat([
{
kind: 'field',
placement: 'own',
key: 'isEmployee',
initializer: () => {
return value;
},
descriptor: {
configurable: true,
writable: false,
enumerable: true,
},
},
]);
return {
kind,
elements: newElements,
};
};
}
const greet = new Greeter();
greet; // Greeter { name: 'John', isEmployee: false }
If we didn't want to send a parameter to the decorator, we could have done the following:
function isEmployee(descriptor) {
const { kind, elements } = descriptor;
const newElements = elements.concat([
{
kind: 'field',
placement: 'own',
key: 'isEmployee',
initializer: () => {
return false;
},
descriptor: {
configurable: true,
writable: false,
enumerable: true,
},
},
]);
return {
kind,
elements: newElements,
};
}
core-decorators.js
I recommend that you read the proposal and spec for decorators since it contains crucial information about them: it discusses built-in decorators (that is going to be part of the JavaScript language) amongst other things. Furthermore, I also recommend checking out core-decorators.js, which allows you to use some stage-0 decorators as defined per the TC39 specification.
Hopefully, this article has given you a good overview of the current state of decorators. They are undoubtedly useful, and we can use them today in TypeScript without any issues (using the appropriate flags), but JavaScript support is also on its way. I tried to outline some of the quirks that I have run into while experimenting with this feature - it's clear that changes are being implemented, but once they'll become part of the JavaScript standard, they will give some needed extra functionality for developers.