JavaScript decorators have been a language feature since ES2015 came out, but they are still largely ‘experimental’ in JavaScript engines that support it. What are they? If you’re familiar with Java, you know how methods can be wrapped through annotations. The gist of it: a decorator wraps a JavaScript class method within another function, and it is invoked by annotation.
I’ll be using NodeJS for the example code, so there will be several modules, plugins, and configurations that are needed.
1. initialize an npm project in a new folder:
npm init -y
2. install the babel command-line tools; we’ll be needing this to transpile the decorated methods:
npm i --save-dev babel-cli
3. install plugins need for the transpilation:
npm install --save-dev babel-eslint babel-plugin-transform-decorators-legacy babel-polyfill babel-preset-env babel-register eslint eslint-plugin-node
4. Create a .babelrc file to your project with these settings:
{
"presets": ["env"],
"plugins": ["transform-decorators-legacy"]
}
Additional note: If you are using VSCode as an editor, go to Settings and turn on Experimental Decorators to make any warnings go away.
5. To run sample code, add the following run target in package.json:
"scripts": {
"start": "babel-node yourdecoratedcode.js --require babel-polyfill"
},
Before delving into parameter injection, let's first look at the ubiquitous @log example and later tweak it a bit. It is a rather plain decorator, of which you can find many variations in many languages.
const log = (target, name, descriptor) => {
/*
target: the class instance of the method
name: the name of the method
descriptor:
value: the method itself
*/
const original = descriptor.value; // hold onto the original function
if (typeof original === 'function') { //ensure that it is a function
// substitute a new function for the original, this is what will be called instead
descriptor.value = function (...args) {
const result = original.apply(this, args); // call the now-wrapped original
console.log(`${name}(${args}) = ${result}`)
}
}
}
The comments should make clear what is going on. The log function is the decorator, which wraps the class method that follows the decorator @log annotation. To use it, a class method is annotated with:
class MyClass {
@log
sum(a, b) {
return a + b;
}
}
const instance = new MyClass()
instance.sum(2, 3); // execute the decorated method
// the decorator dumps this string to the console:
// sum(2,3) = 5
The console output shown as a comment at bottom comes from line 15 of the previous listing.
With a little bit of digging, I was able to find code examples that illustrate a means of obtaining parameter names from a method signature. Now let’s have the decorator dump not just the parameter values, but the parameter names as well:
var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg;
var ARGUMENT_NAMES = /([^\s,]+)/g;
function getParamNames(func) {
var fnStr = func.toString().replace(STRIP_COMMENTS, '');
var result = fnStr.slice(fnStr.indexOf('(') + 1, fnStr.indexOf(')')).match(ARGUMENT_NAMES);
if (result === null)
result = [];
return result;
}
const log2 = (target, name, descriptor) => {
const original = descriptor.value;
if (typeof original === 'function') {
const paramNames = getParamNames(original)
descriptor.value = function (...args) {
const params = paramNames.reduce((obj, pn, i) => {
obj[pn] = args[i];
return obj;}, {} )
const result = original.apply(this, args);
console.log(`${name}(${JSON.stringify(params)}) = ${result}`)
}
}
}
class MyClass {
@log2
sum(a, b) {
return a + b;
}
}
const instance = new MyClass();
instance.sum(4, 5); // decorator outputs: sum2({"a":4,"b":5}) = 9
From the last line, the decorator will output
sum2({"a":4,"b":5}) = 9
With decorators, you’re not actually limited to the arguments that you are given, but can infer parameter values from within the decorator, passing those onto the wrapped method.
The following decorator is going to infer by parameter name the argument values to be passed in. Instead of the decorator dumping a string to the console, we'll have the wrapped function log its parameter values:
const insertStuff = (target, name, descriptor) => {
const original = descriptor.value;
if (typeof original === 'function') {
const paramNames = getParamNames(original)
descriptor.value = function () {
const args = paramNames.reduce((arr, pn, i) => {
arr[i] = this.newStuff[pn];
return arr;}, [] )
const result = original.apply(this, [...args]);
// console.log(`${name}(${JSON.stringify(args)}) = ${result}`)
}
}
}
class MyClass {
@insertStuff
getStuff(isWombat, sugar) {
console.log({isWombat, sugar})
}
}
This decorator will pass as arguments to the original function whatever is in
MyClass.newStuff.isWombat
and MyClass.newStuff.sugar
at the time the function is called; for instance, the following lists (via getStuff()
) both the original values of stuff, and then the new values:const stuff = {
isWombat: true,
sugar: ["in the morning", "in the evening", "at suppertime"]
}
instance.newStuff = stuff;
instance.getStuff();
instance.newStuff.sugar = ["You are my candy, girl", "and you keep me wanting you"];
instance.getStuff();
And the output is…
{ isWombat: true,
sugar: [ 'in the morning', 'in the evening', 'at suppertime' ] }
{ isWombat: true,
sugar: [ 'You are my candy, girl', 'and you keep me wanting you' ] }
Well, that wraps it up. All code in this article can be found here.