Composing JavaScript Decorators
Tags: webdev, javascript
Published: 2024-07-10 01:33:34

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](#best-practices)!

- [Context and Specification](#context-and-specification)
- [Preface](#preface)
- [Composing Decorators](#composing-decorators)
    - [Non-Functional Chaining Example](#nonfunctional-chaining-example)
    - [Writing Composable Decorators](#writing-composable-decorators)
    - [Order Matters](#order-matters)
    - [Best Practices](#best-practices)

## Context and Specification

The [Decorators Proposal on GitHub](https://github.com/tc39/proposal-decorators) and [my previous article on decorators](https://dev.to/frehner/javascript-decorators-and-auto-accessors-437i) 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](https://babeljs.io/repl#?browsers=defaults%2C%20not%20ie%2011%2C%20not%20ie_mob%2011&build=&builtIns=false&corejs=3.21&spec=false&loose=false&code_lz=Q&debug=false&forceAllTransforms=false&modules=false&shippedProposals=false&circleciRepo=&evaluate=true&fileSize=false&timeTravel=false&sourceType=module&lineWrap=false&presets=env%2Creact%2Cstage-2&prettier=false&targets=&version=7.24.7&externalPlugins=&assumptions=%7B%7D), 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](https://dev.to/frehner/javascript-decorators-and-auto-accessors-0-temp-slug-979005?preview=a74f965083163194f3db492b71b3b55162692b20ef9cc16fa970ea901af13a31d52d998dad6ecc7df2a94c63a58e4ed8d62a040f08f7e3b630edd6c0), 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](https://babeljs.io/repl#?browsers=defaults%2C%20not%20ie%2011%2C%20not%20ie_mob%2011&build=&builtIns=false&corejs=3.21&spec=false&loose=false&code_lz=GYVwdgxgLglg9mABAUwG7LAZwPICdsAmBmAFAgDYCeAoulogLyJS4jICUiA3gFCKK5kUELiShIsBIgLIIcXAEMo8kqgXk2AGkRywUZAA8onXvwCQ5IYhh7ko9QDkQAWwBGdxogAMfRGcHCoty-_PwA5kIkJiGh_kIiSDb69uRObnYx_AC-mpmImJFq5NGhsbqYUIhgLp5p7riq6ux5ZjDAJDCYDgoOJNXO7CWlsQD0I9IIAOSVBZVQABbIiEVs1sDWUJOYVXCVClUu9XnmAQnWtil1GcN-WS1tfTUApIgATIgAhAxMZGBUtBhtgB-byIABciAAjINgmY4acgkk7GBHIdrsMzHcbq0LijUmjcJ4iscBPFEbjUelcHksbdfHc7jxxNB4EhyHAwtgwABlSIAB0EwBgBk8kwUIGU7LCEVwkyGCLE4BZUhkckUygaK2Q2l0-iMQ34CuCN1mjWKxpuOgQmDglgAdFKSALkEKDNois1LaTAkhiTdadl6TxGRByApMNsALKUADCYYjFoAAmhAXhCMQSCw2J7EImpVzeVASJMU2AALRSmVguW-BQQCDICPyRDOGh0K7U_i-PMcguRSZwIgVjlVmv8ZN0HD4IikYDqAo5usNpuE1vpjvBng8cpzZDOPmeMDIADuiGjcfDpGa5VtyAdHOLpYhMjnIHIlS1k20-n3dtbALADtmh4H8-T_NsMA7UVFnIdlJm3a1b3vMJHzoCEFGAZJmFwSgbDCZg4HyKwbCKGACGWdQ2C_Zg9zA_92wJYDQPAgCoKYABmBCsCQx0SzQxAMKwlhcLAfDlCIuZCMHcj-nqajmPoyDGK3BSIMAglPAAFi4m17V4p8BMwjxZlgUSCJQOgDipeTaJYhiqWAsZEDLFzXLclytxvPSHwHIhnxdcV3wojRkBs381yIICVNsiKCDYxBJhguCdJ4nzpPQozCWEvDzNmc5SPIz9vxiyh12UkCSrKqlPE4ry7149LDKEnCcvEvLxOkqy5OK8LSsi8rVKq-otJS7yUN8ggMqwkzWsI0sursMK6L6uLGIAbiAA&debug=false&forceAllTransforms=false&modules=false&shippedProposals=false&circleciRepo=&evaluate=true&fileSize=false&timeTravel=false&sourceType=module&lineWrap=false&presets=env%2Creact%2Cstage-2&prettier=false&targets=&version=7.24.7&externalPlugins=&assumptions=%7B%7D)

```js
// 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 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](https://babeljs.io/repl#?browsers=defaults%2C%20not%20ie%2011%2C%20not%20ie_mob%2011&build=&builtIns=false&corejs=3.21&spec=false&loose=false&code_lz=GYVwdgxgLglg9mABAUwG7LAZwPICdsAmBmAFAgDYCeAoulogLyJS4jICUiA3gFCKK5kUELiShIsBIgLIIcXAEMo8kqgXk2AGkRywUZAA8onXvwCQ5IYhh7ko9QDkQAWwBGdxogAMfRGcHCoty-_PwA5kIkJiGh_kIiSDb69uRObnYx_AC-mpmImJFq5NGhsbqYUIhgLp5p7riq6ux5ZjDAJDCYDgoOJNXO7CWlsQD0I9IIAOSVBZVQABbIiEVs1sDWUJOYVXCVClUu9XnmAQnWtil1GcN-WS1tfTUApIgATIgAhAxMZGBUtBhtgB-byIABciAAjINgmY4acgkk7GBHIdrsMzHcbq0LijUmjcJ4iscBPFEbjUelcHksbdfHc7jxxNB4EhyHAwtgwABlSIAB0EwBgBk8kwUIGU7LCEVwkyGCLE4BZUhkckUygaK2Q2l0-iMQ34CuCN1mjWKxpuOgQFUQArQ8BAmF5UGSADV1KsmFqAHSzb0QdTkEgLTraIqcIEg4mWq1YOCWb1Skh2oUGbR21AOp1CN0ejgko0ZrPO3MaZA0mK0hk8HgQcgKTDbACylAAwvXGxaAAJoQF4QjEYOsfP8LtSrnOkiTXtgAC0UplYLlvgUEAgyEb8kQzhodCu1P4vjHHInkUmcCI845i-Xo5nOHwRFIwHUBWa_FX683hJ3A_3PEZco5mQZw-U8MBkAAd0QFt2wbUhmnKeNkETDkpxnCEZBfEByEqLVJm0fRQO9HcATAfdmh4Ii-RI3cMH3UVFnIdlJlra1kNQsJ0LoCEFGAZJmFwSgbDCZg4HyKwbCKGACGWPMCOYECaNIvcCUo6jaLIhimAAZjYuMEyTaceMQPiBJYYSwFE5QJLmcSL1k_p6gUjSVPotSa1cujyIJTwABZ9MwDijIw0z-I8WZYCssSUDoA4qRcpTNNUqlKLGRBZ0yrLstnGskMMtDzyITDkGw3C5LLRLiN_IgKM8pKaoIbTEEmJiWMC4LCoc3jwsJCyRJi2Zzmk2T8MIhrKD_DyqImqaqU8PT8pQozurC8yhIGmyhpshz4uc8bqsm2rpq8ub6n8jqCq4oqCB6gTIs28SZz2uwquUo6mrUgBuIA&debug=false&forceAllTransforms=false&modules=false&shippedProposals=false&circleciRepo=&evaluate=true&fileSize=false&timeTravel=false&sourceType=module&lineWrap=false&presets=env%2Creact%2Cstage-2&prettier=false&targets=&version=7.24.7&externalPlugins=&assumptions=%7B%7D)

```js
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](https://babeljs.io/repl#?browsers=defaults%2C%20not%20ie%2011%2C%20not%20ie_mob%2011&build=&builtIns=false&corejs=3.21&spec=false&loose=false&code_lz=GYVwdgxgLglg9mABAUwG7LAZwPICdsAmBmAFAgDYCeAoulogLyJS4jICUiA3gFCKK5kUELiShIsBIgLIIcXAEMo8kqgXk2AGkRywUZAA8onXvwCQ5IYhh7ko9QDkQAWwBGdxogAMfRGcHCoty-_PwA5kIkJiGh_AD0cdZgqHAA1sjMABYZAA6CqPAgmIgRUPq42q4gUIgUlAJCIkhwIohq5DAEiGAu7rgxse1sAHSlwxDq5CRQmTCY7AN-AU1J5WCOvXaLAL6ai5iR7dGxsbqYNXlohZgAykLlAGrqbJ5DyMMHUOOT07OY2kdEAB-IFtdSLMxnGo9ZyeJxuOwkS4FFq3e52J4aDgQmDAEhzBwKBwkGHsY4ncwJaQIADkNU-WQyb2swGsUBpxTAcBqCm6m36FPMyyCNjWGwRApOZm2OLxMMQAFJEAAmRAAQgYTDIYCotAwxVBXkQAC5EABGMnBMzW4VIUV2dbkeF9CEyilme32J3817gwUNQJ22xe51bCluvwRmUynjiaDwJDkOBhbBgO5QJGCYAwAyeGkKapwJNhCK4Gnk22IOOSJAyOSKZS4VTPZDaXT6Izk_iV0wnT7N8hdk5QxDI67px4t31Yj5Cb7kKYzOYA9ScEFg8iLfhnIvvYuZ5DZgzaMeoicYlsLf2V09Fc-4TFsHYxKM8GMQcgKTDFACylAAwp-37BPwAACaD6nghDENMrDYmBxapumJA0hBYAALTFqWxrlr4CgQBAyDfvIiDODQdChpKvigYhaaRDScBEJhybYbhYFoTg-BEKQwDqAcV6IPhhHEbgpGUNBlFvjwPBUjuljjJYChNgsPAjvozg5J4YDIAA7ogf6AV-pALHJe7JihaGmjIvEgOQNRvDS2jqTkwxkXqYCUSpzmueRGCUXm2QLnANKqQgmC7sM-6oXQpoKMA5TMLglA2GEzBwIgDI2O0nQbmwjnMMgGk-e5nnSd5bkUT6TAAMyhVgEVRZZgnxR4LDJWAqXKBlVhdYxXQwn0-Xlb5Hn8l5hUuRVflVYgAAsdXhfJjUxc1CWfLAHVpSgdB8hKQ0TcVlUSipVLoWd50XdJpmReZDFEFZh4FnZuXIPtRVkRJY1lQdH1EP5TA0oFSYhddUV9bFLWiW1KVbZlyTqDlDlOT94l_V9PDDZ9EqeLVoO3eDq2tUlMNdQyvVELtg3I-9qMEKVGMo1jfSePNeNhChBNxWt9wk-laGU3Yb2TbTnkANxAA&debug=false&forceAllTransforms=false&modules=false&shippedProposals=false&circleciRepo=&evaluate=true&fileSize=false&timeTravel=false&sourceType=module&lineWrap=false&presets=env%2Creact%2Cstage-2&prettier=false&targets=&version=7.24.7&externalPlugins=&assumptions=%7B%7D)

```js
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: 

```js
// 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](https://babeljs.io/repl#?browsers=defaults%2C%20not%20ie%2011%2C%20not%20ie_mob%2011&build=&builtIns=false&corejs=3.21&spec=false&loose=false&code_lz=GYVwdgxgLglg9mABAUwG7LAZwPICdsAmBmAFAgDYCeAoulogLyJS4jICUiA3gFCKK5kUELiShIsBIgLIIcXAEMo8kqgXk2AGkRywUZAA8onXvwCQ5IYhh7ko9QDkQAWwBGdxogAMfRGcHCoty-_PwA5kIkJiGh_AD0cdZgqHAA1sjMABYZAA6CqPAgmIgRUPq42q4gUIgUlAJCIkhwIohq5DAEiGAu7rgxse1sAHSlwxDq5CRQmTCY7AN-AU1J5WCOvXaLAL6ai5iR7dGxsbqYNXlohZgAykLlAGrqbJ5DyMMHUOOT07OY2kdEAB-IFtdSLMxnGo9ZyeJxuOwkS4FFq3e52J4aDgQmDAEhzBwKBwkGHsY4ncwJaQIADkNU-WQyb2swGsUBpxTAcBqCm6m36FPMyyCNjWGwRApOZm2OLxMMQAFJEAAmRAAQgYTDIYCotAwxVBXkQAC5EABGMnBMzW4VIUV2dbkeF9CEyilme32J3817gwUNQJ22xe51bCluvwRmUynjiaDwJDkOBhbBgO5QJGCYAwAyeGkKapwJNhCK4Gnk22IOOSJAyOSKZS4VTPZDaXT6Izk_iV0wnT7N8hdk5QxDI67px4t31Yj5Cb7kKYzOYA9ScEFg8iLfhnIvvYuZ5DZgzaMeoicYlsLf2V09Fc-4TFsHYxKM8GMQcgKTDFACylAAwp-37BPwAACxapumJA0mgGAALTFqWxrlr4oGwVgeCEMQ0ysNi_AKBAEDIN-8iIM4NB0KGkqoRBaaRDScBEAhyZIShYHoTg-BEKQwDqAcV6IARREkbgZGUFhVFvjwPA7pY4yWAoTYLDJCDnMwyDODknhgMgADuiB_oBX6kAssl7sm0HoaaMi8SA5A1G8NLaPomnDORepgFRykuTkbkURgVF5tkC5wDS0k-X5HmBUwADM4Uab57mUT6TAACzSVScFZdlOXSWZwz7gxRDWYeBb2RubBOeprnkRJ_LeQlfl1RKQXICFYU8BFtVENFiBxZ1jXdQQvWpUAA&debug=false&forceAllTransforms=false&modules=false&shippedProposals=false&circleciRepo=&evaluate=true&fileSize=false&timeTravel=false&sourceType=module&lineWrap=false&presets=env%2Creact%2Cstage-2&prettier=false&targets=&version=7.24.7&externalPlugins=&assumptions=%7B%7D)

```js
// 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!