Exploring the Possibilities of Native JavaScript Decorators
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 instage 3 (tc39/proposal-decorators
)â 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 decoratordesign pattern
This is thehigher-level conceptof augmenting or extending a functionâs behavior by âdecoratingâ it. Logging is a common example. You might want to knowwhenandwith whatparameters 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 2
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 beendecorated.
Decorators as afeature 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
Despite being non-standard, there are a number of popular, mature libraries out there that have used this implementation.TypeORM,Angular, andNestJSare 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 (babel/babel
)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, 2
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 todebouncethose 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);
});
But decorators can only be used on a class or its members, so letâs flesh out a better example. Youâve got aViewController
class with a method for handlingkeyup
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);
});
Using thedebounce()
method we wrote above, implementation would be clunky. Focusing in on theViewController
class itself:
class ViewController {
handleSearch = debounce(async function (query) {
const results = await search(query);
console.log(`Got results!`, results);
});
}
You not only need to wrap yourentiremethod, 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 thatdebounce()
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);
}
}
Thatâs all it takes â a single line â for the exact same result.
We can also make the debouncing delay configurable by makingdebounce()
accept adelay
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);
};
};
}
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);
}
}
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 abouthow elegant it isin the past; the||=
operator is all you really need:
def results
@results ||= calculate_results
end
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;
};
}
The nice thing about this is that each invocation of a decorator declares its own scope, meaning you can reuse it without risk of thecachedValue
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: 34
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;
};
}
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]); // fresh
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 usecontext.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;
};
}
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 cache
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 gettersandmethods 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;
};
}
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;
}
}
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();
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);
};
}
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 aninject()
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;
});
};
}
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 theEmailService
for example, but itâs never actually instantiated, we wonât unnecessarily boot up an instance ofEmailService
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();
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.
});
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 throughthe proposal (tc39/proposal-decorators
), 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.