Composing JavaScript Decorators
Anthony Frehner
Posted on July 10, 2024
A walkthrough and best practices guide on how to compose JavaScript decorators that use auto-accessors.
Table of Contents
Consider skipping straight to Best Practices!
Context and Specification
The Decorators Proposal on GitHub and my previous article on decorators already do a great job of breaking down the basic use-cases of decorators. My goal isn't to recreate those examples there, but instead to highlight some lesser-known features and interactions. Additionally, I'll highlight how to compose or chain multiple decorators on a single class property.
Preface
Each code sample will come with a link to an interactive Babel REPL playground, so you can try it for yourself without needing to set up a polyfill or spin up a repo. The "Evaluate" option in the top left (under Settings
) should be checked in all my examples, which means that you will be able to see the code, edit it, open your browser's dev console, and see the logs / results there.
You don't need to pay attention to the transpiled code on the right-hand side of the Babel REPL, unless you want to dig into the polyfill for decorators. The left-hand side of the Babel REPL is where you can edit and write code to try out for yourself.
To emphasize, your developer tools' console should show console logs. If it doesn't, make sure that Evaluate
is checked in the top left.
Composing Decorators
One important use-case that the proposal doesn't show is how to compose (or combine multiple) decorators on a single property. This is a powerful tool which helps keep your decorators clean and reusable, so let's dig into it.
In my previous article on decorators, it's was annoying to have to manually console.log
after changing the value, so let's make a decorator that does that automatically for us - all while keeping the evensOrOdds
decorator there, too!
Non-Functional Chaining Example
My first impression when working with chaining decorators is that it would be as simple as adding the decorator and things would "Just Work". Unfortunately, that isn't the case; in this example below, I've added the logOnSet
decorator before and after a property in order to demonstrate the issue. Babel REPL
// THIS DOES NOT WORK CORRECTLY, DO NOT USE
// code collapsed from previous examples
function evensOrOdds(){}
function logOnSet(prefix = 'autologger') {
return function decorator(value, context) {
return {
set(val) {
console.log(prefix, val)
return val
}
}
}
}
class MyClass {
@evensOrOdds(true)
@logOnSet('even-logger:')
accessor myEvenNumber
@logOnSet('odd-logger:')
@evensOrOdds(false)
accessor myOddNumber
}
If you open the Babel REPL and look at the console logs, you may notice a couple of things:
-
even-logger
never shows up π€ -
myOddNumber
is always0
, even though theodd-logger
shows that there's a different value that's trying to be set π€
Despite the order we place the decorators in, nothing seems to be working as we expect it to. What's going on?
Writing Composable Decorators
There's an important line in the decorators proposal that affects how multiple decorators work:
Accessor decorators receive the original underlying getter/setter function as the first value, and can optionally return a new getter/setter function to replace it. Like method decorators, this new function is placed on the prototype in place of the original (or on the class for static accessors), and if any other type of value is returned, an error will be thrown.
In other words, the last-applied decorator's setter/getter methods win out, and none of the other decorators setters/getters are applied.
Since only one setter/getter is ever applied, how would we chain decorators?
Fortunately, our decorator is passed the previous decorator's setter/getter methods (or the native getter/setters if there are no others), so we can call them ourselves! Let's update the logOnSet
decorator to do this, using the value
parameter that the decorator is passed: Babel REPL
function logOnSet(prefix = 'autologger') {
return function decorator(value, context) {
return {
set(val) {
const previousSetterValue = value.set.call(this, val) ?? val
console.log(prefix, previousSetterValue)
return previousSetterValue
}
}
}
}
That fixes the myOddNumber
property and logger, but myEvenNumber
still isn't working correctly; we need to do the same thing in the evensOrOdds
decorator, too! Babel REPL
function evensOrOdds(onlyEvens = true) {
return function decorator(value, context) {
let internalNumber = 0
return {
get() {
// invoke the previous getter, but only return our valid number
value.get.call(this)
return internalNumber
},
set(val) {
const previousSetterValue = value.set.call(this, val) ?? val
const num = Number(previousSetterValue)
if(isNaN(num)) {
// don't set the value if it's not a number
return internalNumber
}
if(num % 2 !== (onlyEvens ? 0 : 1)) {
return internalNumber
}
internalNumber = val
return internalNumber
}
}
}
}
Note how we're calling the previous getter/setter with .call(this, val)
, which helps ensure that the this
value is consistent. Importantly, we're also using the nullish coalescing operator ?? val
at the end to ensure that, if the previous setter/getter doesn't return anything, we keep the original val
and use that.
Order Matters
Now that it's all working, let's take another close look at the logs. You may note that the even-logger
logs the value before it is validated by our evensOrOdds
decorator, while the odd-logger
logs the value after the value has been validated.
It's important to note that the order of our chained decorators is different for each property:
// weird ordering on decorators
class MyClass {
@evensOrOdds(true)
@logOnSet('even-logger:')
accessor myEvenNumber
@logOnSet('odd-logger:')
@evensOrOdds(false)
accessor myOddNumber
}
Decorators are executed from bottom-to-top, just like normal JavaScript functions are executed right-to-left: last(first())
. We want our logger to be logging values after validation, so let's reorder the decorators on myEvenNumber
(and finally remove all the other console logs): Babel REPL
// better ordering on decorators
class MyClass {
@logOnSet('even-logger:')
@evensOrOdds(true)
accessor myEvenNumber
@logOnSet('odd-logger:')
@evensOrOdds(false)
accessor myOddNumber
}
Those logs look better, and we've only had to write our console log logic once, too!
Best Practices
A TLDR of the "Writing Composable Decorators" section.
- Call the the previous decorator's setter/getter:
value.set.call(this, val)
to enable chaining - Fall back to the original value if the previous decorator's setter/getter doesn't return anything:
value.set.call(this, val) ?? val
- Return a value in your setter/getter, so that other decorators can easily chain it:
return internalNumber
- Ensure the order is correct - decorators execute from bottom-to-top!
Posted on July 10, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.