Composing JavaScript Decorators

frehner

Anthony Frehner

Posted on July 10, 2024

Composing JavaScript Decorators

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
}
Enter fullscreen mode Exit fullscreen mode

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 always 0, even though the odd-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
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!
πŸ’– πŸ’ͺ πŸ™… 🚩
frehner
Anthony Frehner

Posted on July 10, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related