Search code examples
typescriptpropertiesdecoratordefineproperty

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 ?


Solution

  • 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.