DEV Community

Morokhovskyi Roman
Morokhovskyi Roman

Posted on • Edited on

Guide to JavaScript Decorators (last proposal)

JavaScript Decorators is a powerful feature that finally reached stage 3 and is already supported by Babel.
Here you can read the official proposal https://github.com/tc39/proposal-decorators.

I want to guide you on seamlessly activating the latest decorator feature support for your application. I made a straightforward plan to dive into the decorator feature.

Predefine environment

In order to try the latest feature of decorators, you can create .babelrc file

{
  "presets": [
    ["@babel/env", {
      "targets": {
        "node": "current"
      }
    }]
  ],
  "plugins": [
    ["@babel/plugin-proposal-class-static-block"],
    ["@babel/plugin-proposal-decorators", { "version": "2022-03" }],
    ["@babel/plugin-proposal-class-properties", { "loose": false }]
  ]
}
Enter fullscreen mode Exit fullscreen mode

And here is the package.json, what you can take as an example:

{
  "scripts": {
    "build": "babel index.js -d dist",
    "start": "npm run build && node dist/index.js"
  },
  "devDependencies": {
    "@babel/cli": "^7.19.3",
    "@babel/core": "^7.20.5",
    "@babel/plugin-proposal-decorators": "^7.20.5",
    "@babel/preset-env": "^7.20.2",
    "@babel/preset-stage-3": "^7.8.3"
  }
}

Enter fullscreen mode Exit fullscreen mode

Introduction

Probably you are already familiar with Typescript decorators, and you may see it somewhere, it looks like "@myDecorator". Declared decorators start from "@" and can be applied for classes, methods, accessors, and properties.
Here are some examples you may see before:

function myDecorator(value: string) {
  // this is the decorator factory, it sets up
  // the returned decorator function
  return function (target) {
    // this is the decorator
    // do something with 'target'
    return function(...args) {
       return target.apply(this, args)
    } 
  };
}

class User {

   @myDecorator('value')
   anyMethod() { }
}
Enter fullscreen mode Exit fullscreen mode

Javascript supports natively decorators, which haven’t been part of standard ECMAScript yet but are experimental features. Experimental means it may be changed in future releases. That is what happened. Actually, the latest proposal of the decorators introduced a new syntax and more accurate and purposeful implementation.

Decorators in Typescript

Javascript has been introducing decorators since 2018. And TypeScript support decorators as well, with "experimentalDecorators" enabled option

{
  "compilerOptions": {
   ...
    "experimentalDecorators": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice
TypeScript doesn’t support the last proposal of decorators
https://github.com/microsoft/TypeScript/pull/50820

Nevertheless Typescript provides enough powerful and extended functionality around decorators:

  • Decorator Composition (we can wrap few times an underlying function)
class ExampleClass {
  @first()
  @second()
  method() {}
}
Enter fullscreen mode Exit fullscreen mode

It is equal to:

first(second(method()))

// or the same as
const enhancedMethod = first(method());
const enhancedTwiceMethod = second(enhancedMethod())
Enter fullscreen mode Exit fullscreen mode
  • Parameter Decorators (Unlike JS, TS decorators support decorating params)
class User {

    getRole(@inject RBACService) {
        return RBACService.getRole(this.id)
    }
}
Enter fullscreen mode Exit fullscreen mode

The feature is included in the plans for Typescript version 5.0.

How do decorators work?

The decorator basically high order functions. It's a kind of wrapper around another function and enhances its functionality without modifying the underlying function.
Here example of the legacy version:

function log(target, name, descriptor) {
    const original = descriptor.value;
    if (typeof original === 'function') {
        descriptor.value = function(...args) {
            console.log(`Arguments: ${args}`);
            try {
                const result = original.apply(this, args);
                console.log(`Result: ${result}`);
                return result;
            } catch (e) {
                console.log(`Error: ${e}`);
                throw e;
            }
        }
    }
    return descriptor;
}
Enter fullscreen mode Exit fullscreen mode

And the new implementation (that became stage-3 of the proposal):

function log(target, { kind, name }) {
    if (kind === 'method') {
        return function(...args) {
            console.log(`Arguments: ${args}`);
            try {
                const result = target.apply(this, args);
                console.log(`Result: ${result}`);
                return result;
            } catch (e) {
                console.log(`Error: ${e}`);
                throw e;
            }
        }
    }
}

class User {

    @log
    getName(firstName, lastName) {
        return `${firstName} ${lastName}`
    }
}
Enter fullscreen mode Exit fullscreen mode

As you can see there are new options, we don't use descriptors anymore to change an object, but we got closer to metaprogramming approach. I really like how Axel Rauschmayer describes what metaprogramming is:

  • We don’t write code that processes user data (programming).
  • We write code that processes code that processes user data (metaprogramming).

Let's take a look closer at the new signature of decorators, here is the new type well described in TS (but hasn’t merged to master yet), we will use it just as an example

type Decorator = (
  value: DecoratedValue,
  context: {
    kind: string;
    name: string | symbol;
    addInitializer(initializer: () => void): void;

    // Don’t always exist:
    static: boolean;
    private: boolean;
    access: {get: () => unknown, set: (value: unknown) => void};
  }
) => void | ReplacementValue;
Enter fullscreen mode Exit fullscreen mode
  • Kind parameter can be:
    'class'
    'method'
    'getter'
    'setter'
    'accessor'
    'field'
    Kind property tells the decorator which kind of JavaScript construct it is applied to.

  • Name is the name of a method or field in a class.

  • addInitializer allows you to execute code after the class itself or a class element is fully defined.

Auto-accessors

The decorators' proposal introduces a new language feature: auto-accessors.
To understand what the auto-accessors feature is, let's take a look at the below example:


// New "accessor" keyword 
class User {
    accessor name = 'user';
    constructor(name) {
        this.name = name
    }  
}

// it's the same as
class User {
    #name = 'user';

    constructor(name) {
        this.name = name
    }

    get name() {
        return this.#name;
    }
    set name(value) {
        this.#name = value;
    }
}
Enter fullscreen mode Exit fullscreen mode

Auto-accessor is a shorthand syntax for standard getters and setters that we get used to implementing in classes.
We can apply decorators for accessors easily with the new proposal:

function accessorDecorator({get,set}, {name, kind}) {
    if (kind === 'accessor') {
        return {
            init() {
                return 'initial value';
            },
            get() {
                const value = get.call(this);
                ...
                return value;
            },
            set(newValue) {
                const oldValue = get.call(this);
                ...
                set.call(this, newValue);
            },
        };
    }
}

class User {

    @accessorDecorator
    accessor name = 'user';

    constructor(name) {
        this.name = name
    }  
}
Enter fullscreen mode Exit fullscreen mode

Fields Decorator

How can we decorate fields by legacy approach?
Here code example:

function fieldDecorator(target, name, descriptor) {
    return {
        ...descriptor,
        writable: false
        initializer: () => 'EU'
    }
}

class Customer {
    @fieldDecorator
    country = 'USA';


    getCountry() {
        return this.country
    }
}

const customer = new Customer('john')
customer.getCountry(); // 'EU' instead of USA, because of initializer
customer.country = 'DE' // TypeError: Cannot assign to read only property 'country' of object '#<Customer>'
Enter fullscreen mode Exit fullscreen mode

Limitation legacy decorators:

  • We can't decorate private fields
  • it’s always hacky to decorate efficiently on accessors of fields

The new proposal is more flexible:


function readOnly(value, {kind, name}) {
    if (kind === 'field') {
        return function () {
            if (!this.readOnlyFields) {
                this.readOnlyFields = []
            }
            this.readOnlyFields.push(name)
        }
    }
    if (kind === 'class') {
        return function (...args) {
            const object = new value(...args);
            for (const readOnlyKey of object.readOnlyFields) {
                Object.defineProperty(object, readOnlyKey, { writable: false });
            }
            return object;
        }
    }
}

@readOnly
class Customer {
    @readOnly
    country;
    constructor(country) {
        this.country = country
    }

    getCountry() {
        return this.country
    }
}

const customer = new Customer();

customer.getCountry(); // 'USA'

customer.country = 'EU' // // TypeError: Cannot assign to read only property 'country' of object '#<Customer>'
Enter fullscreen mode Exit fullscreen mode

As you can see, we don't have access to the property descriptor. Still, we can implement it differently, collect all the not writable fields, and set "writable: false" through the class decorator.

Conclusion

I think this is a whole new level, as developers can dive even more into the world of metaprogramming and look forward to the release of Typescript 5.0 and when the new proposal becomes part of the EcmaScript standard.

Follow me on 🐦 Twitter if you want to see more content.

Top comments (0)