user5365075
user5365075

Reputation: 2279

Typescript decorator and Object.defineProperty weird behavior

I'm trying to implement a decorator that overrides a property (1) and defines a hidden property (2). Assume the following example:

function f() {
    return (target: any, key: string) => {

        let pKey = '_' + key;

        // 1. Define hidden property
        Object.defineProperty(target, pKey, {
            value: 0,
            enumerable: false,
            configurable: true,
            writable: true
        });

        // 2. Override property get/set
        return Object.defineProperty(target, key, {
            enumerable: true,
            configurable: true,
            get: () => target[pKey],
            set: (val) => {
                target[pKey] = target[pKey] + 1;
            }
        });
    };
}

class A {
    @f()
    propA = null;
    propB = null;
}

let a = new A();

console.log(Object.keys(a), a.propA, a._propA, a);

Which outputs:

[ 'propB' ] 1 1 A { propB: null }

However, I would rather expect:

[ 'propA', 'propB' ] 1 1 A { propA: 1, propB: null }

since enumerable is true for propA.

Now, if I replace get and set with

get: function () {
    return this[pKey]
},
set: function (val) {
    this[pKey] = this[pKey] + 1;
}

the output is now:

[ '_propA', 'propB' ] 1 1 A { _propA: 1, propB: null }

Though enumerable is explicitly set to false for _propA in f.

So, as weird as these behaviours can be, I'd like to understand what is going on here, and how I would implement what I'm trying to get ?

Upvotes: 2

Views: 4062

Answers (2)

user5365075
user5365075

Reputation: 2279

Alright, so it took me a while, but I found a workaround. The problem seems to be that Object.defineProperty does not work properly at decoration-time. If you do it at runtime, things go as expected. So, how do you define the property inside the decorator, but at runtime ?

Here is the trick: since overriding the property inside the decorator works at decoration time (only the enumerable behavior seems to be broken), you can define the property but use an initialisation function in place of getter and setter. That function will be run the first time the property is assigned (set) or accessed (get). When that happens, the word this references the runtime instance of your object, which means you can properly initialise what you intended to do at decoration-time.

Here is the solution:

function f() {
    return (target: any, key: string) => {
        let pKey = `_${key}`;

        let init = function (isGet: boolean) {
            return function (newVal?) {
                /*
                 * This is called at runtime, so "this" is the instance.
                 */

                // Define hidden property
                Object.defineProperty(this, pKey, {value: 0, enumerable: false, configurable: true, writable: true});
                // Define public property
                Object.defineProperty(this, key, {
                    get: () => {
                        return this[pKey];
                    },
                    set: (val) => {
                        this[pKey] = this[pKey] + 1;
                    },
                    enumerable: true,
                    configurable: true
                });

                // Perform original action
                if (isGet) {
                    return this[key]; // get
                } else {
                    this[key] = newVal; // set
                }
            };
        };

        // Override property to let init occur on first get/set
        return Object.defineProperty(target, key, {
            get: init(true),
            set: init(false),
            enumerable: true,
            configurable: true
        });
    };
}

Which outputs:

[ 'propA', 'propB' ] 1 1 A { propA: [Getter/Setter], propB: null }

This solution supports default values, because they are assigned after the correct get/set have been initialised.

It also supports enumerable properly: set enumerable to true for property pKey and the output will be:

[ '_propA', 'propA', 'propB' ] 1 1 A { _propA: 1, propA: [Getter/Setter], propB: null }

It's not pretty, I know that, but it works and does not reduce performance as far as I know.

Upvotes: 4

Nikolay Shcherba
Nikolay Shcherba

Reputation: 13

I inspected your code and see that it will define the property twice. I modified your code.

 class A {
    @dec
    public value: number = 5
 }

 function dec(target, key) {
    function f(isGet: boolean) {
      return function (newValue?: number) {
          if (!Object.getOwnPropertyDescriptor(this, key)) {
             let value: number;
             const getter = function () {
               return value
             }

             const setter = function (val) {
                value = 2 * val
             }
              Object.defineProperty(this, key, {
                     get: getter,
                     set: setter,
                     enumerable: true,
                     configurable: true
                     })
          }  
          if (isGet) {
              return this[key]
          } else {
              this[key] = newValue
          }
      }
  }

  Object.defineProperty(target, key, {
      get: f(true),
      set: f(false),
      enumerable: false,
      configurable: false
  })
}

const a = new A()
console.log(Object.keys(a))

And we will get in console

["value"]

Upvotes: 0

Related Questions