How we used JSDoc & Webpack to write some custom JavaScript decorators & annotations

thomas_101

Thomas Beverley

Posted on May 19, 2022

How we used JSDoc & Webpack to write some custom JavaScript decorators & annotations

This article was originally posted on the Wavebox blog

At Wavebox, we use JavaScript for some of our code and we came across an interesting problem (and solution) this week when trying to export some data.

We encapsulate a lot of our data in JavaScript classes/models, this means we can store sparse data and access it through the models, with the models automatically substituting defaults and creating more complex getters for us. As part of a new feature, we want to be able to share of some this data, but not all of it... and this is where we came up with an interesting solution that involves JSDoc decorators & annotations...

The models

We store most of our data structures in classes that wrap the raw data, a simple model looks something like this...

class App {
  constructor (data) {
    this.__data__ = data
  }

  get id () { return this.__data__.id }

  get name () { return this.__data__.name || 'Untitled' }

  get nameIsCustom () { return Boolean(this.__data__.name) }

  get lastAccessed () { return this.__data__.lastAccessed || 0 }
}

const app = new App({ id: 123, name: 'test', lastAccessed: 1000 })
Enter fullscreen mode Exit fullscreen mode

The __data__ variable holds the raw JavaScript object and when accessing something in the model, we normally use a getter that provides the value.

In the above example, we've got some basic getters that just return some data like id. We've also got some getters that return a default if the value doesn't exist like name and lastAccessed.

These models form the a core part of how we manage data and ensure that we don't need to check for undefined's throughout the code, substitute default values and so forth.

Exporting some of the data

We've been working on a new feature that will allow you to share some of your models, but there's a problem. We only want to share some of the data. In our simple App example above, there are some fields we want to share and some we don't...

  • id & name these are good to share 👍
  • nameIsCustom this just works by reading the name field, don't share 🚫
  • lastAccessed we don't want to share this 🙅‍♂️

So lets looks at the most basic example, we can drop nameIsCustom by just reading the raw __data__ object...

console.log(app.__data__)
// { id: 123, name: 'test', lastAccessed: 1000 }
Enter fullscreen mode Exit fullscreen mode

...but this still gives us the lastAccessed field that we don't want. So we went around writing an export function that looks more like this...

class App {
  ...
  getExportData () {
    const { lastAccessed, ...exportData } = this.__data__
    return exportData
  }
}
Enter fullscreen mode Exit fullscreen mode

...looks great. It works! But I predict a problem...

Keeping the code maintainable

The getExportData() function works great, but there's a problem. Some of our models are quite large, and these models will have new fields added in the future. Future me, or future anyone else working on the code is guaranteed to forget to add another exclude to that function and we're going to get a bug. Not so great. So I started thinking about ways we could make this a little bit more maintainable.

Big changes to the models were out of the question, we started with this pattern quite some ago and there are tens of thousands of uses of the models through the code, so whatever we come up with needs to have minimal impact everywhere.

This got me thinking about decorators. I was thinking about a way that I could generate a list of properties to export in the same place that they're defined. This would improve maintainability going forwards.

I came up with some pseudo code in my head that looked something like this...

const exportProps = new Set()
function exportProp () {
  return (fn, descriptor) => {
    exportProps.add(descriptor.name)
  }
}

class App {
  @exportProp()
  get id () { return this.__data__.id }

  @exportProp()
  get name () { return this.__data__.name || 'Untitled' }

  get nameIsCustom () { return Boolean(this.__data__.name) }

  get lastAccessed () { return this.__data__.lastAccessed || 0 }
}

const app = new App({})
Object.keys(app).forEach((key) => { app[key })

console.log(Array.from(exportProps))
// [id, name]
Enter fullscreen mode Exit fullscreen mode

...you can decorate each getter with @exportProp which is nice, but the implementation is far from ideal. In fact it's the sort of code that gives me nauseous 🤢. To begin with, the exported properties now need to run through a decorator before being accessed, there's going to be a performance hit for this. Also to generate the list, you need to create an empty object and iterate over it, although there's nothing wrong with this, it didn't feel particularly nice.

So I started to think about how else we could achieve a similar pattern...

Using JSDoc

This is when I started to think, could we use JSDoc to write some annotations at build time? Doing this would remove the need to generate anything at runtime, keeping the getters performant and allowing us to add an annotation to each property in-situ as required.

I started to play around and came up with this...

class App {
  /**
  * @export_prop
  */
  get id () { return this.__data__.id }

  /**
  * @export_prop
  */
  get name () { return this.__data__.name || 'Untitled' }

  get nameIsCustom () { return Boolean(this.__data__.name) }

  get lastAccessed () { return this.__data__.lastAccessed || 0 }
}
Enter fullscreen mode Exit fullscreen mode

Okay, the comments now span a few more lines, but if it satisfies all the other requirements I can live with that. If we run JSDoc over the file, we get something like this...

[{
  "comment": "/**\n   * @export_prop\n   */",
  "meta": {
    "filename": "App.js",
    "lineno": 61,
    "columnno": 2,
    "path": "/src/demo",
    "code": {
      "id": "astnode100000128",
      "name": "App#id",
      "type": "MethodDefinition",
      "paramnames": []
    },
    "vars": { "": null }
  },
  "tags": [{
    "originalTitle": "export_prop",
    "title": "export_prop",
    "text": ""
  }],
  "name": "id",
  "longname": "App#id",
  "kind": "member",
  "memberof": "App",
  "scope": "instance",
  "params": []
}, ...]
Enter fullscreen mode Exit fullscreen mode

...and hey presto! We get the getter name, and in the list of tags is the export_prop annotation we added. A little bit of looping around on this and we can generate a nice list of property names to export.

Mixing JSDoc & Webpack

You could write a pre-build script to write the docs into a file and then read that in at compile time, but where's the fun in that? We use Webpack for our bundling needs, which means we can write a custom loader. This will run JSDoc over the file for us, play around with the data a little bit and give us a nice output. We can use this output to configure which data comes out the model.

So our Webpack loader can look something a little bit like this, it just runs JSDoc over the input file, strips out everything we don't need and writes the output as a JSON object...

const path = require('path')
const jsdoc = require('jsdoc-api')

module.exports = async function () {
  const callback = this.async()

  try {
    const exportProps = new Set()
    const docs = await jsdoc.explain({ files: this.resourcePath })

    for (const entry of docs) {
      if (entry.kind === 'member' && entry.scope === 'instance' && entry.params && entry.tags) {
        for (const tag of tags) {
          if (tag.title === 'export_prop') {
            exportProps.add(entry.name)
            break
          }
        }
      }
    }
    callback(null, 'export default ' + JSON.stringify(Array.from(exportProps)))
  } catch (ex) {
    callback(ex)
  }
}
...and we just need to update our webpack config to use the loader...

config.resolveLoader.alias['export-props'] = 'export-props-loader.js' 
config.module.rules.push({
  test: /\*/,
  use: {
    loader: 'export-props'
  }
})
Enter fullscreen mode Exit fullscreen mode

...great! That's all the hard work done. Now we can add this to our App model and see what we get out!

import exportProps from 'export-props!./App.js'

class App {
  /**
  * @export_prop
  */
  get id () { return this.__data__.id }

  /**
  * @export_prop
  */
  get name () { return this.__data__.name || 'Untitled' }

  get nameIsCustom () { return Boolean(this.__data__.name) }

  get lastAccessed () { return this.__data__.lastAccessed || 0 }

  getExportData () {
    return exportProps.reduce((acc, key) => {
      if (this.__data__[key] !== undefined) {
        acc[key] = this.__data__[key]
      }
      return acc
    }, {})
  }
}

const app = new App({ id: 123, name: 'test', lastAccessed: 1000 }) 
console.log(app.getExportData())
// { id: 123, name: 'test' }
Enter fullscreen mode Exit fullscreen mode

Hey presto! There it is! Using JSDoc we can generate the list of properties to export at compile time, serialise those into an array and read that out at runtime. We can then use that list to only include what we want in the exported data 👍.

The really great thing is, that we can define which properties are exported next to where they are declared in the hope a future dev will be able to continue along with with pattern.

Taking it one step further

Maybe you've got some properties that need more configuration, or some special behaviours... You can change some of the annotations to look something like this...

class App {
  /**
  * @export_prop isSpecial=true
  */
  get id () { return this.__data__.id }
}
Enter fullscreen mode Exit fullscreen mode

...and then in your loader use...

if (tag.title === 'export_prop') {
  if (tag.value === 'isSpecial=true') {
    // Do something special
  } else {
    exportProps.add(entry.name)
  }
  break
}
Enter fullscreen mode Exit fullscreen mode

If you need it, this gives a way to configure what each one does.

Wrapping up

I thought I'd share this neat little trick, because once you've got the pattern setup it's trivially easy to use. I mean sure, it's a complete mis-use of JSDoc, comments and Webpack loaders, but it works flawlessly, runs at compile time and helps keep our code maintainable. It's a win win!

💖 💪 🙅 🚩
thomas_101
Thomas Beverley

Posted on May 19, 2022

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

Sign up to receive the latest update from our blog.

Related