DEV Community

YashGogia
YashGogia

Posted on

The case for using decorators in your codebase

A decorator is a function that allow us to customize an existing method. It allows for the modification of an object’s behavior — without changing its original code, but extending its functionality.

Decorators are commonly used in some JS frameworks/libraries like Angular and MobX, however they are still not a common design pattern developers used while writing their own code. With this article, I aim to document how we used decorators in my experience and hope to give you some ideas to utilise this design pattern in your own codebase.

Decorators are typically used with classes and prefixed with the @ symbol:

First, let's start with an Example

//value = the function being decorated
//kind = type of property, would be "method" here
//name = name of the function
function logged(value, { kind, name }) {
    return function (...args) {
      console.log(`starting ${name} with arguments ${args.join(", ")}`);
      return value.call(this, ...args);
    };
}

class Example {
  @logged
  method(arg) {
    //Your method's code here
    console.log("Inside method")
  }
}
let ex = new Example()
ex.method(1);

/**
Outputs the following
[LOG]: "starting method with arguments 1" 
[LOG]: "Inside method" 
*/

Enter fullscreen mode Exit fullscreen mode

The logged method here logs all the arguments being passed to a function. These decorators came in handy when we worked on a SDK, where the best way to debug was customer uploaded logs. Adding these decorators ensured code readability and reuse while giving our team an easy way to keep track of all the methods called by our users without much overhead. You can try running this code here

Now let's take a look at how you can include decorators in your codebase

Adding Decorators

Decorators are currently not a part of the standard JavaScript language. They are still being discussed in tc39 and have reached proposal stage 3. This means the spec has more or less stabilized and we can use them but they would be transplied before being run in the browser. This would be done via babel or tsc for most users

Here are the steps to enable decorators using babel:

  • Install the proposal-decorators plugin
npm install --save-dev @babel/plugin-proposal-decorators
Enter fullscreen mode Exit fullscreen mode
  • Add the plugin to .babelrc
{
  "plugins": [
    ["@babel/plugin-proposal-decorators", { "version": "2023-05" }]
  ]
}
Enter fullscreen mode Exit fullscreen mode

More details are available here

In TypeScript, standard decorators will work in principle out of the box. Just make sure:

  • To upgrade to TypeScript to version 5 or higher
  • Make sure the compiler targets "ES2015"or higher
  • Disable or remove the experimentalDecoratorsflag

More examples:

Let's take a look at some other cases where decorators can be useful:

Abstract away repeated checks

Decorators could be very useful for abstracting repeated checks like Validation, Authentication and Authorization. In this example, we'll create a decorator to validate all the inputs to strictSum and strictMulitply methods

function Validator(value, {kind, name}) {
  return function(...args) {
    const validArgs = args.every(arg => Number.isInteger(arg));
    if (!validArgs) {
      throw new TypeError(`Argument passed to ${name} cannot be a non-integer`);
    }
    console.log(`All arguments passed to ${name} are valid`)
    return value(...args);
  }
}

class MathExample {

  @Validator
  strictSum(...args) {
    //add all arguments passed
    let result = args.reduce((acc, val) => acc + val, 0)
    console.log(result)
    return result
  }

  @Validator
  strictMultiply(...args) {
    //multiply all arguments passed
    let result = args.reduce((acc, val) => acc * val, 1)
    console.log(result)
    return result
  }
}
let math = new MathExample()
math.strictSum(1, 2, 3, 4, 5); //Works
math.strictMultiply(1, 2, 3, 4, "5"); //Throws an error
Enter fullscreen mode Exit fullscreen mode

You can play around with this code here

The Validator decorator allows us to be confident that the inputs are going to be numbers. Also, by encapsulating the validation logic in the decorator, we have made our methods much more readable and reused the validation code.

We could even write specific conditions for individual methods by checking the name. For example, we can check the 0 isn't passed to a divide method.

In my team, we use such decorators to authenticate if a user was the moderator of a meeting and had the right to take actions like turn recording on, mute another participant and so on.

Measure time taken by a method

Decorators can be used for doing auxiliary actions like debugging without modifying the original code. Here we will create a custom @measureTime decorator that logs the execution time of a method:

function measureTime(value, {kind, name}) {
  const originalMethod = value;
  return function (...args) {
    const start = performance.now();
    const result = originalMethod.apply(this, args);
    const end = performance.now();
    console.log(`Execution time for ${name}: ${end - start} milliseconds`);
    return result;
  };
}

class Timer {
  @measureTime
  heavyComputation() {
    // Simulate a heavy computation
    for (let i = 0; i < 1000000000; i++) {}
  }
}

const timer = new Timer();
timer.heavyComputation(); // Logs execution time
Enter fullscreen mode Exit fullscreen mode

You can test this code here

Add caching for computationally intensive methods

Another good use case for Decorators can be optimisation. For example, we can use decorators to cache or memoize the results of a computationally intensive method. Let's modify our MathExample class to see this in action

function memoize(value, {kind, name}) {
    let cache = new Map();
    return function(...args) {
      let key = args.join()
      if (cache.has(key)) {
        console.log("returning from cache", cache.get(key))
        return cache.get(key);
      }
      let result = value.apply(this, args); 
      cache.set(key, result);
      return result;
    };
}

class MathExample {
  @memoize
  multiply(...args) {
    //multiply all arguments passed
    let result = args.reduce((acc, val) => acc * val, 1)
    console.log(result)
    return result
  }
}
let math = new MathExample()
math.multiply(1, 2, 3, 4, 5);
math.multiply(1, 2, 3, 4, 5);
math.multiply(1, 2, 3, 4, 5, 6);
math.multiply(1, 2, 3, 4, 5);
math.multiply(1, 2, 3, 4, 5, 6);

/**
 * Output

[LOG]: "returning from cache",  120 

[LOG]: "returning from cache",  120 
[LOG]: "returning from cache",  720 
 */

Enter fullscreen mode Exit fullscreen mode

You can test this code here

Composition

Decorators have the powerful features of being composed and nested. It means we can apply multiple decorators to the same piece of code, and they’ll execute in a specific order. It helps in building complex and modular applications.

Lets combine @memoize and @mesureTime decorators to see composition in action

function memoize(value, {kind, name}) {
    let cache = new Map();
    return function(...args) {
      let key = args.join()
      if (cache.has(key)) {
        console.log("returning from cache", cache.get(key))
        return cache.get(key);
      }
      let result = value.apply(this, args); 
      cache.set(key, result);
      return result;
    };
}

function measureTime(value, {kind, name}) {
  const originalMethod = value;
  return function (...args) {
    const start = performance.now();
    const result = originalMethod.apply(this, args);
    const end = performance.now();
    console.log(`Execution time for ${name}: ${end - start} milliseconds`);
    return result;
  };
}

function wait(ms) {
  let start = Date.now();
  let now = start
  while (now - start < ms) {
    now = Date.now();
  }
}

class MathExample {
  @measureTime
  @memoize
  multiply(...args) {
    //multiply all arguments passed
    let result = args.reduce((acc, val) => acc * val, 1)
    //wait for 1 seconds
    wait(1000)
    console.log(result)
    return result
  }
}
let math = new MathExample()


math.multiply(1,2,3,4,5);
math.multiply(1,2,3,4,5);

/**
 * NOTE: The time might vary for each run, but note the difference between the memoized respons and the normal execution

[LOG]: "Execution time for multiply: 1000.5 milliseconds" 
[LOG]: "returning from cache",  120 
[LOG]: "Execution time for multiply: 0.19999980926513672 milliseconds" 
 */
Enter fullscreen mode Exit fullscreen mode

You can test this code here

There are many more such use-cases like:

  • Retry Mechanism. We can create decorators that automatically retry a method certain number of times in case of failures.

  • Event Handling. Decorators can trigger events before and after a method’s execution, enabling event-driven architectures.

  • Async cases. Decorators can also be used for asynchronous cases like debounce and throttle.

  • Class/property decorators. Decorators can also be applied on classes or individual properties. We've not covered these here, but they work in a similar manner. Please check the proposal for more details

Further reading

I hope that this post demonstrates the flexibility of the decorator design pattern and encourages you to integrate it into your codebase. It's important to note that decorators may not be suitable for every use case. As a general guideline, avoid placing crucial business logic within decorators; instead, utilize them to encapsulate supplementary code.

Top comments (0)