Decorators do not work as you might expect 🤔

Dominic Elm - Jul 16 '19 - - Dev Community

While working on a library called ngx-template-streams, which in a nutshell allows you to work with events as streams in Angular templates, I have discovered that decorators are not instance-based but rather class-based. I was not aware of this behavior and thought that decorators get applied per class instance. In this blog post, we'll take a closer look at decorators and explore why they behave this way and how we can create instance-based decorators.

Cover photo by Garett Mizunaka on Unsplash

Quick Recap on Decorators

Decorators are great. They allow us to add annotations and a meta-programming syntax for class declarations and members, including properties, accessors, parameters, and methods. In other words, we can use decorators to attach additional responsibility to an object without modifying any other object. Therefore, they are great to compose pieces of functionality in a declarative fashion. That means the decorator design pattern is designed in a way that multiple decorators can be stacked on top of each other, each adding new functionality.

Also, many people consider decorators as a flexible alternative to subclassing. While subclassing adds behavior at compile-time, and therefore affecting all instances, decorators add behavior to individual objects at runtime.

So decorators have gained much popularity, and quite frankly for a reason. They make our code easier to read, test, and maintain. Thus, some of the leading open source projects have adopted the decorator design pattern, including Angular, Inversify or Nest.

Ok, so what's a decorator?

Idan Dardikman summarizes this question wonderfully:

Decorators are just a clean syntax for wrapping a piece of code with a function

TypeScript has experimental support for decorators. However, there is an ECMAScript decorator proposal that has reached stage 2 (draft), so they could eventually land in vanilla JS.

As mentioned earlier, there are different types of decorators. For example, we could attach a decorator to a class:

@Component()
class HeroComponent {}
}

The @Component() is an excellent example for a class decorator, and it's one of the core building blocks in Angular. It attaches additional metadata to the class.

Most likely you'll also encounter some property, method or parameter decorators along the way:

@Component()
class HeroComponent {
  @Input() name: string;

  constructor(@Inject(TOKEN) someDependency: number) {}

  @deprecated
  greet() {
    console.log('Hello there!');      
  }
}

So decorators are quite universal, expressive, and powerful. Now, this blog post is not about explaining decorators in all their details. In this post, we implement a property decorator to explore their behavior, but we won't look at the implementation of other types of decorators. If you want to learn more about decorators in general, I highly recommend the official documentation, this gentle introduction or this fabulous series on a variety of topics related to decorators.

The @Clamp Decorator

It's time for an example to understand the behavior that I mentioned in the beginning. The claim was that decorators are not instance-targeted and only called once per class and usage.

To proof this, we'll implement our own property decorator called Clamp.

To use decorators in TypeScript, we have to enable a compiler option called experimentalDecorators. The best place to do this is the tsconfig.json:

{
  "compilerOptions": {
    "target": "ES5",
    "experimentalDecorators": true
  }
}

We can now create a Clamp decorator that we would apply to properties of type number. Its job is to clamp the property value within a specified upper and lower bounds.

For example, if the lower bound is 10 and the upper bound is 50, then our decorated should clamp a value within those bounds:

clamp(5) // => 10
clamp(100) // => 50

We'll implement this functionality later, but first, let's shift our attention to the property decorator.

A property decorator has the following signature:

type PropertyDecoratorType = (target: any, propertyKey: string | symbol) => void;

It's a plain old function with two parameters. The target is the object that owns the decorated property, and the propertyKey is the name of the decorated property. Now, you might be thinking that the target is the instance of a class, but that's not quite the case. The target is simply the prototype of the class, but more on this in just a moment.

The signature above describes a property decorator, and it's well defined. That means the parameters are fixed, and there's no room for extending the signature. However, our decorator is supposed to be configurable and accept a lower and upper bound. Therefore, we have to use the a factory function. In other words, we enclose the decorator method within another method (factory) that defines all configurable options:

function Clamp(lowerBound: number, upperBound: number) {
  return (target: any, propertyKey: string | symbol) => {
    // logic goes here
    console.log(`@Clamp called on '${String(propertyKey)}' from '${target.constructor.name}'`);
  }
}

Nice, we turned a regular decorator into a decorator factory to unleash even more power. Yay!

Before implementing the logic, let's give it a spin! We'll create a class TestBench and decorate some properties with our homemade @Clamp decorator:

class TestBench {
  @Clamp(10, 20)
  a: number;

  @Clamp(0, 100)
  b: number;
}

That's our simple test bench. Note that we are not creating an instance of the TestBench class. So before we run this code, let's do a little quiz:

Question: What do you expect to happen?

  • A: Nothing. The decorator doesn't get called because we are not creating an instance of the class; hence, nothing is logged.
  • B: The decorator factory is called once per class; thus, there will only be one value printed to the console.
  • C: The factory is called twice, once per property; hence, there will be two values printed to the console.
  • D: It explodes.

Ok, drum roll... 🥁🥁🥁

Running this code gives us the following output:

@Clamp called on 'a' from 'TestBench'
@Clamp called on 'b' from 'TestBench'

Tada! Wait, what? So it seems that our decorator function is called twice, once per decorated property. This means the solution to the quiz above is C. In case of doubt, here's a live demo:

The question now is, why, why is the decorator method called without us creating an instance of the class.

Exploring decorators under the hood

To find the answer to this question, we have to dive a bit deeper and see what is actually generated by the TypeScript compiler if we use a decorator. You can either run tsc or copy and paste the code into the TypeScript Playground. No matter what we do, we should get the following transpiled code:

"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
function Clamp(lowerBound, upperBound) {
    return (target, propertyKey) => {
        // logic goes here
        console.log(`@Clamp called on '${String(propertyKey)}' from '${target.constructor.name}'`);
    };
}
class TestBench {}
__decorate([
    Clamp(10, 20)
], TestBench.prototype, "a", void 0);
__decorate([
    Clamp(0, 100)
], TestBench.prototype, "b", void 0);

At first glance this is not easy to grok, especially this somewhat magical __decorate function defined at the top. But this method is pretty important, especially how it's consumed.

So where does __decorate come from and what does it do? This method comes from deep, deep, deep down the compiler and is generated when any type of decorator is used. TypeScript has a helper that produces this code, and it's called decorateHelper. Definitely check out the source code. It's a great learning resource.

Ok, but what does it do? Simply put, it loops over every decorator that is passed in and tries to evaluate them. A detailed explanation is outside the scope of this post. Fortunately, there is an excellent blog post that explains this in-depth.

So let's draw our attention to the bottom of the generated code:

__decorate([
    Clamp(10, 20)
], TestBench.prototype, "a", void 0);
__decorate([
    Clamp(0, 100)
], TestBench.prototype, "b", void 0);

That's where the __decorate function is consumed. Also, we can see that it's called twice, once per decorated property and both get the same target passed in, that is TestBench.prototype. The second argument is the propertyKey and the last argument is a property descriptor. Here, void 0 is used to pass undefined.

The void operator takes an expression, evaluates it and returns undefined. For more information, see the MDN documentation. There is also a great StackOverflow answer that explains the purpose of this tiny operator compared to just using undefined. TL;DR: JavaScript isn't a perfect world, and undefined is not a reserved keyword, meaning that it's just a variable name and we could assign a new value to it.

So the code above is the code that gets generated by the TypeScript compiler, and typically we would load the code in a browser where it gets executed once the file is loaded. In other words, decorators will be applied the moment the classes, in which we use decorators, are loaded. As a result, the decorator (here our property decorator) only has access to the prototype of the class and the property name, but not the instance. This is by design, and it all makes sense, now that we know what gets generated by the compiler.

So far the key takeaway should be that we now know why decorators are not instance-targeted and instead executed when our JavaScript gets loaded in the browser.

It's essential to be aware of this because otherwise, we might experience unexpected behavior. To understand this, we'll have to add logic to our decorator.

The Problem

The fact that decorators are applied when the class is loaded, not when we create instances, is not incorrect and that's actually by design. So what could possibly go wrong?

To find this out, we start off by implementing the actual clamp functionality. So let's create a factory called makeClamp which returns a clamp function with an upper and lower bound. Using a factory function again here makes the functionality more reusable.

function makeClamp(lowerBound: number, upperBound: number) {
  return function clamp(value: number) {
    return Math.max(lowerBound, Math.min(value, upperBound));
  }
}

We can see that this factory returns a clamp method. Here's an example of how we could use this:

const clamp = makeClamp(0, 10);

console.log(clamp(-10)); // => 0
console.log(clamp(0));   // => 0
console.log(clamp(5));   // => 5
console.log(clamp(10));  // => 10
console.log(clamp(20));  // => 10

The examples above should give us a proper understanding of what the decorator is supposed to do. A class property annotated with @Clamp should clip the property value within an inclusive lower and upper bound.

Simply adding this to the decorator function is not enough, because we want the decorator to be operating on an instance and it's supposed to clamp the value of a property every time it's being set.

Let's say we didn't know that the target was only the prototype of a class, so we modify the already existing property on the target using Object.defineProperty. This will allow us, besides other things, to define a getter and setter, which is exactly what we need. Here's what we have to do:

  1. create a desired clamp method using the factory makeClamp.
  2. maintain some internal state used to store the clamped property value.
  3. modify the target property using Object.defineProperty and provide a getter and setter so that we can intercept any modification to the value and run it through our clamp method.

Putting this into code could look like this:

function Clamp(lowerBound: number, upperBound: number) {
  return (target: any, propertyKey: string | symbol) => {
    console.log(`@Clamp called on '${String(propertyKey)}' from '${target.constructor.name}'`);

    // 1. Create clamp method
    const clamp = makeClamp(lowerBound, upperBound);

    // 2. Create internal state variable that holds the clamped value
    let value;

    // 3. Modify target property and provide 'getter' and 'setter'. The 'getter'
    // simply returns the internal state, and the 'setter' will run any new value
    // through 'clamp' and update the internal state.
    Object.defineProperty(target, propertyKey, {
      get() {
        return value;
      },
      set(newValue: any) {
        value = clamp(newValue);
      }
    })
  }
}

Let's also update our test bench, remove one property for simplicity, and create two instances of the test class. Furthermore, we'll set the property to some value:

class TestBench {
  @Clamp(10, 20)
  a: number;
}

const tb1 = new TestBench();
console.log(`Setting 'a' on TB1`)
tb1.a = 30;
console.log(`Value of 'a' on TB1:`, tb1.a);

const tb2 = new TestBench();
console.log(`Value of 'a' on TB2:`, tb2.a);

Running this code will print the following output:

@Clamp called on 'a' from 'TestBench'
Setting 'a' on TB1
Value of 'a' on TB1: 20
Value of 'a' on TB2: 20

Now, this output seems a bit off, doesn't it? We create the first instance tb1 and immediately set property a to 30. This results in the setter to be called, which clamps the value within the specified upper and lower bound. The result should be 20, and that's the case. So far, so good. Then we create another instance tb2 and simply read the property, causing the getter to be called. Somehow this returns 20 even though we haven't set the value on the second instance. Why?

This is what I meant by unexpected behavior, at least if we are not aware of the fact that the target is not the class instance but the prototype. So any modifications on the target will affect every instance because we are globally modifying the prototype of the class. Also, the value that was meant to be an internal state to every decorator is shared across all instances, because they all share the same decorator scope. It is what it is, but for our use case, that's not cool.

Check out this live demo! I highly encourage you to noodle around with the code a bit.

Creating instance-targeted decorators

So what do we do if we want our decorator to be instance-based? We certainly don't want to share state across instances globally.

The solution involves modifying the target property once the decorator is applied, as well as defining a property on the instance with the same property name. In other words, we define a property with a setter on the target prototype that will install a property with the same name, that is propertyKey, on the target instance once it is used for the first time.

Ok, let's take a look at the code. I have added a whole bunch of comments to make it easier to understand what's going on:

function Clamp(lowerBound: number, upperBound: number) {
  return (target: any, propertyKey: string | symbol) => {
    console.log(`@Clamp called on '${String(propertyKey)}' from '${target.constructor.name}'`);

     // Create clamp method
    const clamp = makeClamp(lowerBound, upperBound);

    // Create map to store values associated to a class instance
    const values = new WeakMap();   

    // Define property on the target with only a `setter` because we don't
    // want to read from the prototype but instead from the instance.
    // Once the value of the property is set for the first time we define
    // a property with a `getter` and `setter` on the instance.
    Object.defineProperty(target, propertyKey, {
      set(newValue: any) {
        console.log('set on target');

        // This `setter` gets called once per new instance, and only the 
        // first time we set the value of the target property.

        // Here we have access to the instance `this`, so we define 
        // a property with the same name on the class instance.
        Object.defineProperty(this, propertyKey, {
          get() {
            console.log('get on instance');
            // This `getter` gets called every time we read the instance property.
            // We simply look up the instance in our map and return its value.
            return values.get(this);
          },
          set(newValue: any) {
            console.log('set on instance');
            // This `setter` is called every time we set the value of the 
            // property on the class instance.
            values.set(this, clamp(newValue));
          }
        });

        // Finally we set the value of property on the class instance.
        // This will trigger the `setter` on the instance that we defined above.
        return this[propertyKey] = newValue;
      }
    })
  }
}

Essentially, we are using Object.defineProperty inside Object.defineProperty but with different objects. The first one uses the target which is the class prototype, and the second one uses this which refers to the class instance.

Also, note that we are using a WeakMap at the top of the decorator to store the property value for each instance. A WeakMap is a special kind of Map but the difference is that a WeakMap doesn't prevent an object from being garbage collected even though this object is used as the key in the WeakMap. If you want to learn more, check out this fantastic blog post which explains the differences really well.

Alright, let's give this revised version of our decorator a spin and see if it is really instance-targeted and if it no longer shares state across all instances of the same class. For that, I have slightly updated our test bench and added a few comments:

// When this class gets loaded, the decorator is applied and executed.
// This will define the `setter` for the target property on the prototype
// of this class.
class TestBench {
  @Clamp(10, 20)
  a: number;
}

const tb1 = new TestBench();

// This should return `undefined` because we didn't define a `getter`
// on the target prototype for this property. We only install a `getter`
// once we set the value for the first time.
console.log(`Reading 'a' on TB1`, tb1.a);

// This calls the `setter` for `target.a` and defines a property with 
// a `getter` and `setter` on the class instance for the same property.
tb1.a = 30;

// From this moment on, every time we read the value for this property
// we would call the most inner `getter`.
console.log(`Reading 'a' on TB1`, tb1.a);

// The same applies for updating the value. This will call the `setter`
// that we defined for the property of the class instance.
console.log(`Updating 'a' on TB1`);
tb1.a = 15;

// Creating a new instance doesn't do anything
const tb2 = new TestBench();

// Remember, we have globally defined a getter for `target.a` and because we
// are operating on a new instance, the target setter will be called which
// will set up the property on the new instance with their own `getter`
// and `setter` methods.
console.log(`Setting 'a' on TB2`);
tb2.a = 5;

console.log(`Reading 'a' on TB2:`, tb2.a);

// Remains unmodified because every instance has it's own property defined
// with their own `getter` and `setter`
console.log(`Reading 'a' on TB1:`, tb1.a);

Tada! It seems to be working. We have just implemented our own decorator that works on an instance level rather than being prototype-based. I mean it still involves modifying the prototype, but now every decorator also operates on a single instance, and they are all isolated from one another.

Check out the final solution and definitely play around with the code:

Bonus

The above illustrates a full-blown solution, but while I was writing this blog post Netanel Basal pointed out to me a solution that is much more concise and cleaner. It doesn't require a double call to Object.defineProperty, because he found out that the return value is not ignored, as opposed to what's mentioned in the documentation, and is in fact used as an input for a call to Object.defineProperty.

With that in mind, we can reduce our solution from above to the following, which has the exact same behavior:

function Clamp(lowerBound: number, upperBound: number): any {
  return (target: any, propertyKey: string | symbol) => {
    const clamp = makeClamp(lowerBound, upperBound);

    // We need a unique key here because otherwise we would be
    // calling ourselves, and that results in an infinite loop.
    const key = Symbol();

    // We can return a property descriptor that is used to define 
    // a property on the target given the `propertyKey`.
    return {
      get() {
        // Read the value from the target instance using the
        // unique symbol from above
        return this[key]; 
      },
      set(newValue: any) { 
        // Clamp the value and write it onto the target instance
        // using the unique symbol from above
        this[key] = clamp(newValue);
      }
    }
  }
}

Now, this is pretty clean, isn't it? 🔥

Here's a live demo:

Conclusion

Decorators are class and property-based, meaning they are applied and executed once per decorated property when the class gets loaded. This means the target is not the class instance but the prototype of the class. Any changes made to the target are made globally, and if we try to use the decorator scope to maintain some internal state, that state is being shared across all instances of the same class, and they all use the same decorator scope. This could lead to unexpected behavior.

However, in this article, we have seen a solution that involves a double Object.defineProperty with different targets to make a decorator instance-based.

Hopefully, by now, you have a better understanding of how decorators work and why they behave the way they do.

If you enjoyed this post feel free to give it a thumbs up and let me know if you have any questions or comments!

Special Thanks

I’d like to thank Netanel Basal and Manfred Steyer for reviewing the article and providing valuable feedback. 🙏

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .