Decorators in JavaScript
In my last post, I started exploring the benefits of NestJS as a backend framework. One thing that stood out to me was how heavily the framework relies on TypeScript decorators. I thought that they felt nice to use and decided to take a closer look in order to better understand what they are, how they work, and when they are useful.
What is a Decorator?
Decorators are an object-oriented design pattern described by the Gang of Four. Broadly speaking, a decorator is something that wraps and extends the behavior of something else without actually modifying the underlying behavior of that object. Using functions in JavaScript, we would typically accomplish this with composition, but how would we do the same thing with ES6 classes?
Let's consider an example where we want to add logging to a class' methods. A simple solution would be to inject and assign a logger object to a private member of the class via its constructor. Each method would then call the logger as needed, such as before and after the method logic. This solution works fine, but we can now imagine that testing this class' methods becomes more difficult since it depends on a logger and has side-effects (emitting logs). Additionally, what if we need to log all of our methods?
A cleaner solution would be to create a new class that implements a shared interface, but adds the additional logging behavior that we desire:
interface ICar {drive: () => void;}class Car implements ICar {drive() {// start driving}}class Logger implements ICar {constructor(private car: Car,) {}drive() {console.log('Executing "drive"');this.car.drive();console.log('Done executing "drive"');}}const car = new Car();const loggedCar = new Logger(car);/** Produces the following output:* 'Executing "drive"'* 'Done executing "drive"'*/loggedCar.drive();
Note that I use TypeScript in the example above to demonstrate that both classes implement the same interface. Wrapping our car
instance in the Logger class allows us to keep logging logic separate from our car logic. The advantage of this pattern is that we can arbitrarily modifying the runtime behavior of a given class without having to change the class itself.
The above pattern allows us to decorate our existing JavaScript ES6 classes (simply exclude the ICar
TypeScript interface), but there is a language-specific proposal for adding decorator support to JavaScript that is currently in stage two, meaning it will likely be in the language in the future. The decorator proposal allows us to write a function that we can reference with the @
symbol in our ES6 classes. JavaScript decorators can be used at the class, method, accessor, property, or parameter level. The advantage of using JavaScript decorators is that they give us more control over how we choose to pin specific behavior to different aspects of our class. Building and using decorators in this way is a lot more declarative and reduces the need to write additional classes. Instead of building a new logging class, like above, we can write a function that logs before and after a method call.
Using the proposed JavaScript decorator syntax, our code from above can be refactored like the following:
function log(fn, { name }) {return function(...args) {console.log('Executing "' + name + '"');// pass our args through to the underlying functionconst returnValue = fn.apply(this, args);console.log('Done executing "' + name + '"');return returnValue;}}class Car {// attach our logging decorator@logdrive() {console.log('This car is driving!');}}const car = new Car();/** Produces the following output:* 'Executing "drive"'* 'This car is driving!'* 'Done executing "drive"'*/car.drive();
As we can see, using decorators makes modifying the behavior of our drive
method much simpler. Additionally, our log
function no longer needs to know about the class or its interface which means it can be used freely by any other class. One last advantage is that we can apply as many decorators as we want and they will be executed bottom to top (the lowest decorator will get executed first), allowing us to really get creative with how we use them.
As mentioned before, decorators have not officially landed in JavaScript yet. Consequently, we need to use some kind of transpiler, like TypeScript, to use them today. In the rest of the examples, I will be using TypeScript, but one thing to note is that the API TypeScript uses for decorators is slightly different than the TC39 proposal. Lastly, TypeScript support for decorators is still experimental and requires certain flags to be set in order to work.
We can see the slight differences in TypeScript's decorator API below. One of the most obvious differences is that the JavaScript decorator expects a function to be returned if wrapping the runtime behavior (see the example above), whereas TypeScript expects the descriptor.value
field to be rewritten with the modified function:
function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {const { value } = descriptor;if (typeof value === 'function') {// modify descriptor.value directly to wrap the functiondescriptor.value = function (...args: Array<any>) {console.log('Executing "' + propertyKey + '"');value.call(this, args);console.log('Done executing "' + propertyKey + '"');}}}class Car {@logdrive() {console.log('This car is driving!');}}const car = new Car();/** Produces the following output:* 'Executing "drive"'* 'This car is driving!'* 'Done executing "drive"'*/car.drive();
How do Decorators Work?
Now that we have a better idea of what decorators are and what they look like in JavaScript and TypeScript, let's explore how they work by implementing a version ourselves. We'll keep things simple and focus on implementing a version of decorators that works on class methods.
Before we jump in to the code, let's discuss what we'll need in order for this to work. First, we know that we'll need to create a function that takes in an array of the decorators we want to apply to a specific class method. We also know that this function will need to take the target class, as well as the name of the method (property key). We will expect that the underlying object property is mutated by this function, allowing us to void any return value. So far, we have the following function signature:
function decorate(decorators: Array<string>, target: any, key: string): void {}
The next thing that we need to understand is how to actually modify a class method. Unlike plain object properties and static class members, regular members and methods only exist on an instance of a class. In JavaScript a class is simply syntactical sugar on top of a function. To demonstrate this, the following code creates two "classes" that are identical in behavior, but differ in implementation:
const CarFunction = (function () {function CarFunction(milesPerGallon) {this.milesPerGallon = milesPerGallon;}CarFunction.prototype.calculateFuelConsumption = function (milesTraveled) {return milesTraveled / this.milesPerGallon;};return CarFunction;}());class CarClass {constructor(milesPerGallon) {this.milesPerGallon = milesPerGallon;}calculateFuelConsumption(milesTraveled) {return milesTraveled / this.milesPerGallon;}};const carA = new CarFunction(30);const carB = new CarClass(30);console.log(carA.calculateFuelConsumption(300)); // 10console.log(carB.calculateFuelConsumption(300)); // 10console.log(CarFunction.prototype.calculateFuelConsumption.call({ milesPerGallon: 30 }, 300)); // 10console.log(CarClass.prototype.calculateFuelConsumption.call({ milesPerGallon: 30 }, 300)); // 10
As we can see in this example, classes are simply functions and their methods and members are properties that exist on its prototype. In order to access a property stored on a class' prototype, we can use Object.getOwnPropertyDescriptor
, which will return metadata about the property, along with its value. This metadata can be useful for further modifying the behavior of the property itself, such as making it readonly. See Mozilla's docs for more information on propertyDescriptors
.
Finally, we need to consider how we will call this function. We obviously won't be cutting into the language itself to get the nice @
syntax that TypeScript provides, but we will still be able to call this as a normal function, applying an array of decorators to the target's property. Below is an example of how we might apply this to our Car class from above:
decorate([log,emitMetrics,], Car.prototype, 'drive');
In this example, we have passed an array of decorators in the same order they would be listed above the method we'd like to apply them to. Our target is the prototype of our Car
class and the property we are decorating is its drive
method.
Now that we have a rough idea of what our function needs and how to call it, let's go ahead and start implementing. Let's start by getting our propertyDescriptor
:
const descriptor = Object.getOwnPropertyDescriptor(target, key);if (descriptor == null) {throw new Error('Unable to get propertyDescriptor');}
Next we need to loop over our array of decorators. Note that we need to do this in reverse order so that our decorators run in compositional order (evaluate top-down, but run bottom-up):
for (var i = decorators.length - 1; i >= 0; i--) {const decoratorFn = decorators[i];decoratorFn(target, key, descriptor);}
This bit of code is a little deceptive because it's actually doing quite a bit. Each time a decorator function is called, it takes in the descriptor from above. In the decorators we looked at in the first section, they are actually mutating the value key on this descriptor, thereby mutating the underlying function. So each time we wrap descriptor.value
, we are composing an additional layer of code around our method.
Finally we have one last step. Our propertyDescriptor
object is mutable, but only changes to its value
key are actually reflected in the original object because value
is just a reference to the underlying property on the prototype. If we wanted to modify another metadata field, like configurable
, we need to ensure that we are writing our mutated propertyDescriptor back to our target:
Object.defineProperty(target, key, descriptor);
That's it! All together, our decorate function now looks like the following:
type DecoratorFn = (target: any, key: string, descriptor: PropertyDescriptor) => void;function decorate(decorators: Array<DecoratorFn>, target: any, key: string): void {const descriptor = Object.getOwnPropertyDescriptor(target, key);if (descriptor == null) {throw new Error('Unable to get propertyDescriptor');}for (var i = decorators.length - 1; i >= 0; i--) {const decoratorFn = decorators[i];decoratorFn(target, key, descriptor);}Object.defineProperty(target, key, descriptor);}
Now we're ready to do some decorating 💅! In the example below, I've pulled in our logging function from earlier and added a new function that emits metrics. I haven't implemented any logic for actually emitting these metrics, but have left comments showing where we could do so. Depending on where this code is running (frontend vs. backend), what type of metrics you want to collect, and how much work you want to put in, you could use tools like Sentry, Segment, Datadog, or Prometheus to instrument your JavaScript code.
// a decorator function that logs method callsfunction log(target, propertyKey, descriptor) {const { value } = descriptor;if (typeof value === 'function') {// modify descriptor.value directly to wrap the methoddescriptor.value = function (...args) {console.log('Executing "' + propertyKey + '"');value.call(this, args);console.log('Done executing "' + propertyKey + '"');}}}// a decorator function that emits metrics about our application behaviorfunction emitMetrics(target, propertyKey, descriptor) {const { value } = descriptor;if (typeof value === 'function') {// modify descriptor.value directly to wrap the methoddescriptor.value = function (...args) {console.log('Starting timer...');const startTime = Date.now();try {value.call(this, args);console.log('Sweet success!');// emit success metrics here} catch (err) {console.error('Error!', err);// emit failure metrics here before re-throwing to the callerthrow err;} finally {const latencyMs = Date.now() - startTime;console.log('Elapsed time: ' + latencyMs + 'ms');// emit latency metrics here}};}}class Car {drive() {console.log('This car is driving!');}}decorate([log,emitMetrics,], Car.prototype, 'drive');const car = new Car();/** Produces the following output:* 'Executing "drive"'* 'Starting timer...'* 'This car is driving!'* 'Sweet success!'* 'Elapsed time: 1ms'* 'Done executing "drive"'*/car.drive();
When to use Decorators
In this post we've explored what decorators are and how they work. One question remains, though - when does it make sense to use decorators?
My answer to this question is simple: "well, it depends!"
The biggest advantage of using decorators is that they encourage encapsulation and reuse of logic that is class agnostic. We saw this in our logging example - instead of having to add logging calls into each method of our code, we wrote a single decorator and attached it to the method we wanted to log calls to. If we had several classes and methods that needed logging, we'd be able to share this decorator with all of those methods. This keeps our code clean by seperating logging logic from domain logic.
On the other hand, the decorators I have written in this post all produce some form of side-effect (writing logs and emitting metrics). Even though the decorator syntax is quite readable, it is not always clear what effect the decorator could have on that method at first glance. In this way, decorators can make your code harder to understand. Often times, the most maintainable solution will be the simplest one.
Additionally, the fact remains that decorator support has not yet landed in JavaScript and is experimental in TypeScript, meaning the current API could break in a future version. With this being said, new versions of TypeScript generally have great documentation on breaking changes and provide examples to help migrate your code.
In conclusion, I think that the advantage of code-reuse outweighs the negatives of using decorators in TypeScript projects. Once they land in JavaScript officially, I will be even more convinced. While I don't think we should refactor all of our existing TypeScript classes to utilize decorators, they do offer a really clean way to customize classes with generalized logic using a declarative syntax that can be leveraged moving forward.
For now, I'm going to continue using decorators in my own TypeScript projects where the cost of a breaking API change is a couple hours of debugging and maybe a beer or two.
🍻