Search
View Details

Exploring the Possibilities of Native JavaScript Decorators

We’ve known it for a while now, but JavaScript is eventually getting native support for decorators. The proposal is in stage 3 — it’s inevitable! I’m just coming around to explore the feature, and I’m kinda kicking myself for waiting so long, because I’m finding it to be tremendously helpful. Let’s spend some time exploring it.

The Pattern vs The Feature

It’s probably worth clarifying what’s meant by a “decorator.” Most of the time, people are talking about one of two things:

The decorator design pattern

This is the higher-level concept of augmenting or extending a function’s behavior by “decorating” it. Logging is a common example. You might want to know when and with what parameters it’s called, so you wrap it with another function:

function add(a, b) {
  return a + b;
}

function log(func) {
  return function (...args) {
    console.log(
      `method: ${func.name} | `,
      `arguments: ${[...args].join(", ")}`
    );
    return func.call(this, ...args);
  };
}

const addWithLogging = log(add);

addWithLogging(1, 2);
// adding 1 2Code language: JavaScript (javascript)

There’s no new language-specific feature here. One function simply accepts another as an argument and returns a new, souped-up version. The original function has been decorated.

Decorators as a feature of the language

The decorator feature is a more tangible manifestation of the pattern. It’s possible you’ve seen an older, unofficial version of this before. We’ll keep using the logging example from above, but we’ll first need to refactor a bit because language-level decorators can only be used on class methods, fields, and on classes themselves.

// The "old" decorator API:

function log(target, key, descriptor) {
  const originalMethod = descriptor.value;

  descriptor.value = function (...args) {
    console.log(
      `method: ${originalMethod.name} | `,
      `arguments: ${[...args].join(", ")}`
    );

    return originalMethod.apply(this, args);
  };

  return descriptor;
}

class Calculator {
  @log // <-- Decorator applied here.
  add(a, b) {
    return a + b;
  }
}

new Calculator().add(1, 2); // method: add | arguments: 1, 2
Code language: JavaScript (javascript)

Despite being non-standard, there are a number of popular, mature libraries out there that have used this implementation. TypeORMAngular, and NestJS are just a few of the big ones. And I’m glad they have. It’s made building applications with them feel cleaner, more expressive, and easier to maintain.

But because it’s non-standard, it could become problematic. For example, there’s some nuance between how it’s implemented by Babel and TypeScript, which probably caused frustration for engineers moving between applications with different build tooling. Standardization would serve them well.

The Slightly Different Official API

Fortunately, both TypeScript (as of v5) and Babel (via plugin) now support the TC39 version of the API, which is even simpler:

function log(func, context) {
  return function (...args) {
    console.log(
      `method: ${func.name} | `,
      `arguments: ${[...args].join(", ")}`
    );

    func.call(this, ...args);
  };
}

class Calculator {
  @log
  add(a, b) {
    return a + b;
  }
}

new Calculator().add(1, 2); // method: add | arguments: 1, 2Code language: JavaScript (javascript)

As you can see, there’s much less of a learning curve, and it’s fully interchangeable with many functions that have been used as decorators until now. The only difference is that it’s implemented with new syntax.

Exploring the Use Cases

There’s no shortage of scenarios in which this feature will be handy, but let’s try out a couple that come to mind.

Debouncing & Throttling

Limiting the number of times an action occurs in a given amount of time is an age-old need on the web. Typically, that’s meant reaching for a Lodash utility or rolling an implementation yourself.

Think of a live search box. To prevent user experience issues and network load, you want to debounce those searches, only firing a request when the user has stopped typing for a period of time:

function debounce(func) {
  let timeout = null;

  return function (...args) {
    clearTimeout(timeout);

    timeout = setTimeout(() => {
      func.apply(this, args);
    }, 500);
  };
}

const debouncedSearch = debounce(search);

document.addEventListener('keyup', function(e) {
  // Will only fire after typing has stopped for 500ms.
  debouncedSearch(e.target.value);
});  Code language: JavaScript (javascript)

But decorators can only be used on a class or its members, so let’s flesh out a better example. You’ve got a ViewController class with a method for handling keyup events:

class ViewController {
  async handleSearch(query) {
    const results = await search(query);

    console.log(`Update UI with:`, results);
  }
}

const controller = new ViewController();

input.addEventListener('keyup', function (e) {
  controller.handleSearch(e.target.value);
});Code language: JavaScript (javascript)

Using the debounce() method we wrote above, implementation would be clunky. Focusing in on the ViewController class itself:

class ViewController {
  handleSearch = debounce(async function (query) {
    const results = await search(query);

    console.log(`Got results!`, results);
  });
}Code language: JavaScript (javascript)

You not only need to wrap your entire method, but you also need to switch from defining a class method to an instance property set to the debounced version of that method. It’s a little invasive.

Updating to a Native Decorator

Turning that debounce() function into an official decorator won’t take much. In fact, the way it’s already written fits the API perfectly: it accepts the original function and spits out the augmented version. So, all we need to do is apply it with the @ syntax:

class ViewController {
  @debounce
  async handleSearch(query) {
    const results = await search(query);

    console.log(`Got results!`, results);
  }
}
Code language: JavaScript (javascript)

That’s all it takes — a single line — for the exact same result.

We can also make the debouncing delay configurable by making debounce() accept a delay value and return a decorator itself:

// Accept a delay:
function debounce(delay) {
  let timeout = null;

  // Return the configurable decorator:
  return function (value) {
    return function (...args) {
      clearTimeout(timeout);

      timeout = setTimeout(() => {
        value.call(this, ...args);
      }, delay);
    };
  };
}Code language: JavaScript (javascript)

Using it just means calling our decorator wrapper as a function and passing the value:

class ViewController {
  @debounce(500)
  async handleSearch(query) {
    const results = await search(query);

    console.log(`Got results!`, results);
  }
}
Code language: JavaScript (javascript)

That’s a lot of value for minimal code wrangling, especially support being provided by TypeScript and Babel — tools already well-integrated in our build processes.

Memoization

Whenever I think of great memoization that’s syntactically beautiful, Ruby first comes to mind. I’ve written about how elegant it is in the past; the ||= operator is all you really need:

def results
  @results ||= calculate_results
endCode language: Ruby (ruby)

But with decorators, JavaScript’s making solid strides. Here’s a simple implementation that caches the result of a method, and uses that value for any future invocations:

function memoize(func) {
  let cachedValue;

  return function (...args) {
    // If it's been run before, return from cache.
    if (cachedValue) {
      return cachedValue;
    }

    cachedValue = func.call(this, ...args);

    return cachedValue;
  };
}Code language: JavaScript (javascript)

The nice thing about this is that each invocation of a decorator declares its own scope, meaning you can reuse it without risk of the cachedValue being overwritten with an unexpected value.

class Student {
  @memoize
  calculateGPA() {
    // Expensive computation...
    return 3.9;
  }

  @memoize
  calculateACT() {
    // Expensive computation...
    return 34;
  }
}

const bart = new Student();

bart.calculateGPA();
console.log(bart.calculateGPA()); // from cache: 3.9

bart.calculateACT();
console.log(bart.calculateACT()); // from cache: 34Code language: JavaScript (javascript)

Going further, we could also memoize based on the parameters passed to a method:

function memoize(func) {
  // A place for each distinct set of parameters.
  let cache = new Map();

  return function (...args) {
    const key = JSON.stringify(args);

    // This set of parameters has a cached value.
    if (cache.has(key)) {
      return cache.get(key);
    }

    const value = func.call(this, ...args);

    cache.set(key, value);

    return value;
  };
}Code language: JavaScript (javascript)

Now, regardless of parameter usage, memoization can become even more flexible:

class Student {
  @memoize
  calculateRank(otherGPAs) {
    const sorted = [...otherGPAs].sort().reverse();

    for (let i = 0; i <= sorted.length; i++) {
      if (this.calculateGPA() > sorted[i]) {
        return i + 1;
      }
    }

    return 1;
  }

  @memoize
  calculateGPA() {
    // Expensive computation...
    return 3.4;
  }
}

const bart = new Student();

bart.calculateRank([3.5, 3.7, 3.1]); // fresh
bart.calculateRank([3.5, 3.7, 3.1]); // cached
bart.calculateRank([3.5]); // freshCode language: JavaScript (javascript)

That’s cool, but it’s also worth noting that you could run into issues if you’re dealing with parameters that can’t be serialized (undefined, objects with circular references, etc.). So, use it with some caution.

Memoizing Getters

Since decorators can be used on more than just methods, a slight adjustment means we can memoize getters too. We just need to use context.name (the name of the getter) as the cache key:

function memoize(func, context) {
  let cache = new Map();

  return function () {
    if (cache.has(context.name)) {
      return cache.get(context.name);
    }

    const value = func.call(this);

    cache.set(context.name, value);

    return value;
  };
}Code language: JavaScript (javascript)

Implementation would look the same:

class Student {
  @memoize
  get gpa() {
    // Expensive computation...
    return 4.0;
  }
}

const milton = new Student();

milton.gpa // fresh
milton.gpa // from the cacheCode language: JavaScript (javascript)

That context object contains some useful bits of information, by the way. One of those is the “kind” of field being decorated. That means we could even take this a step further by memoizing the getters and methods with the same decorator:

function memoize(func, context) {
  const cache = new Map();

  return function (...args) {
    const { kind, name } = context;

    // Use different cache key based on "kind."
    const cacheKey = kind === 'getter' ? name : JSON.stringify(args);

    if (cache.has(cacheKey)) {
      return cache.get(cacheKey);
    }

    const value = func.call(this, ...args);

    cache.set(cacheKey, value);

    return value;
  };
}Code language: JavaScript (javascript)

You could take this much further, but we’ll draw the line there for now, and instead shift to something a little more complex.

Dependency Injection

If you’ve worked with a framework like Laravel or Spring Boot, you’re familiar with dependency injection and the “inversion of control (IoC) container” for an application. It’s a useful feature, enabling you to write components more loosely coupled and easily testable. With native decorators, it’s possible to bring that core concept to vanilla JavaScript as well. No framework needed.

Let’s say we’re building an application needing to send messages to various third-parties. Triggering an email, sending an analytics event, firing a push notification, etc. Each of these are abstracted into their own service classes:

class EmailService {
  constructor() {
    this.emailKey = process.env.EMAIL_KEY;
  }
}

class AnalyticsService {
  constructor(analyticsKey) {
    this.analyticsKey = analyticsKey;
  }
}

class PushNotificationService {
  constructor() {
    this.pushNotificationKey = process.env.PUSH_NOTIFICATION_KEY;
  }
}Code language: JavaScript (javascript)

Without decorators, it’s not difficult to instantiate those yourself. It might look something like this:

class MyApp {
  constructor(
    emailService = new EmailService(),
    analyticsService = new AnalyticsService(),
    pushNotificationService = new PushNotificationService()
  ) {
    this.emailService = emailService;
    this.analyticsService = analyticsService;
    this.pushNotificationService = pushNotificationService;

    // Do stuff...
  }
}

const app = new MyApp();Code language: JavaScript (javascript)

But now you’ve cluttered your constructor with parameters that’ll never otherwise be used during runtime, and you’re taking on full responsibility for instantiating those classes. There are workable solutions out there (like relying on separate modules to create singletons), but it’s not ergonomically great. And as complexity grows, this approach will become more cumbersome, especially as you attempt to maintain testability and stick to good inversion of control.

Dependency Injection with Decorators

Now, let’s create a basic dependency injection mechanism with decorators. It’ll be in charge of registering dependencies, instantiating them when necessary, and storing references to them in a centralized container.

In a separate file (container.js), we’ll build a simple decorator used to register any classes we want to make available to the container.

const registry = new Map();

export function register(args = []) {
  return function (clazz) {
    registry.set(clazz, args);
  };
}Code language: JavaScript (javascript)

There’s not much to it. We’re accepting the class itself and optional constructor arguments needed to spin it up. Next up, we’ll create a container to hold the instances we create, as well as an inject() decorator.

const container = new Map();

export function inject(clazz) {
  return function (_value, context) {
    context.addInitializer(function () {
      let instance = container.get(clazz);

      if (!instance) {
        instance = Reflect.construct(clazz, registry.get(clazz));
        container.set(clazz, instance);
      }

      this[context.name] = instance;
    });
  };
}Code language: JavaScript (javascript)

You’ll notice we’re using something else from the decorator specification. The addInitializer() method will fire a callback only after the decorated property has been defined. That means we’ll be able to lazily instantiate our injected dependencies, rather than booting up every registered class all at once. It’s a slight performance benefit. If a class uses the EmailService for example, but it’s never actually instantiated, we won’t unnecessarily boot up an instance of EmailService either.

That said, here’s what’s going on when the decorator is invoked:

  • We check for any active instance of the class in our container.
  • If we don’t have one, we create one using the arguments stored in the registry, and store it in the container.
  • That instance is assigned to the name of the field we’ve decorated.

Our application can now handle dependencies a little more elegantly.

import { register, inject } from "./container";

@register()
class EmailService {
  constructor() {
    this.emailKey = process.env.EMAIL_KEY;
  }
}
@register()
class AnalyticsService {
  constructor(analyticsKey) {
    this.analyticsKey = analyticsKey;
  }
}
@register()
class PushNotificationService {
  constructor() {
    this.pushNotificationKey = process.env.PUSH_NOTIFICATION_KEY;
  }
}

class MyApp {
  @inject(EmailService)
  emailService;

  @inject(AnalyticsService)
  analyticsService;

  @inject(PushNotificationService)
  pushNotificationService;

  constructor() {
    // Do stuff.
  }
}

const app = new MyApp();Code language: JavaScript (javascript)

And as an added benefit, it’s straightforward to substitute those classes for mock versions of them as well. Rather than overriding class properties, we can less invasively inject our own mock classes into the container before the class we’re testing is instantiated:

import { vi, it } from 'vitest';
import { container } from './container';
import { MyApp, EmailService } from './main';

it('does something', () => {
  const mockInstance = vi.fn();
  container.set(EmailService, mockInstance);

  const instance = new MyApp();
  
  // Test stuff.
});Code language: JavaScript (javascript)

That makes for less responsibility on us, tidy inversion of control, and straightforward testability. All made easy by a native feature.

Just Scratching the Surface

If you read through the proposal, you’ll see that the decorator specification is far deeper than what’s been explored here, and will certainly open up some novel use cases in the future, especially once more runtimes support it. But you don’t need to master the depths of the feature in order to benefit. At its foundation, the decorator feature is still firmly seated on the decorator pattern. If you keep that in mind, you’ll be in a strong position to greatly benefit from it in your own code.

It's time to take your JavaScript to the next level

Frontend Masters logo

Frontend Masters is the best place on the web to really learn JavaScript. We have a complete learning path from the biggest and best teachers in JavaScript to help you make the most out of the web's biggest language.

One response to “Exploring the Possibilities of Native JavaScript Decorators”

  1. Avatar Barrett says:

    Great article! thanks so much for taking the time to write it Alex.

Leave a Reply

Your email address will not be published. Required fields are marked *

Frontend Masters ❤️ Open Source

Did you know? Frontend Masters Donates to open source projects. $313,806 contributed to date.