Search code examples
angularjsjsonangular-decoratorreflect-metadata

Typescript: decorators behave differently on angular project and typescript playground


I need to serialize an object to json in angular 2.0.0-rc1 when I found out that Typescript's private isn't private at all, and get set property are not outputted through JSON.stringify.

So I set out to decorate the class:

//method decorator
function enumerable(value: boolean) {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        descriptor.enumerable = value;
    };
}
//property decorator
function exclude(target: any, propertyKey: string): any {
    return { enumerable: false };
}
class MyClass {
    test: string = "test";
    @exclude
    testExclude: string = "should be excluded";
    @enumerable(true)
    get enumerated(): string {
        return "yes";
    }
    @enumerable(false)
    get nonEnumerated(): string {
        return "non enumerable"
    }
}

let x = new MyClass();
//1st
console.log(JSON.stringify(x));
//2nd
console.log(JSON.stringify(x, Object.keys(MyClass.prototype)));
//3rd
console.log(JSON.stringify(x, Object.keys(x).concat(Object.keys(MyClass.prototype))));//test 3

on Typescript playground, this gives

{"test":"test"}
{"enumerated":"yes"}
{"test":"test","enumerated":"yes"}

but on my project (angular 2.0.0-rc1), this gives

{"test":"test","testExclude":"should be excluded"}
{"enumerated":"yes"}
{"test":"test","testExclude":"should be excluded","enumerated":"yes"}

What I'm really after is output #3 from the playground.

After taking a look at the transpiled code, the only difference is reflect-metadata's code:

//snip ...

    __decorate([
        exclude, 
        __metadata('design:type', String)
    ], MyClass.prototype, "testExclude", void 0);
    __decorate([
        enumerable(true), 
        __metadata('design:type', String)
    ], MyClass.prototype, "enumerated", null);
    __decorate([
        enumerable(false), 
        __metadata('design:type', String)
    ], MyClass.prototype, "nonEnumerated", null);
    return MyClass;
}());

none of that __metadata lines in playground.

What's happening in here? And how can I achieve playground's #3 result on my project?


Solution

  • Fixed it (or might be just a workaround).

    Notice that in playground, Reflect-metadata is not available. Property decorators can return an object to be assigned (ORed) to the descriptor to change its behaviour. In angular environment, Reflect-metadata (specifically Reflect.decorate()) is used instead to decorate things.

    After reading up on reflect-metadata doc, and this, apparently there's no way to change PropertyDescriptor on property decorator since it is tied to the constructor instead of the prototype. A solution(workaround) would be to recreate the property with the new descriptor.

    function include(value: boolean) {
        return function (target: any, propertyKey: string): any {
            // Buffer the value
            var _val = target[propertyKey];
            // Delete property.
            if (delete target[propertyKey]) {
                // Create new property with getter and setter
                Object.defineProperty(target, propertyKey, {
                    get: () => _val,
                    set: (newVal) => _val = newVal,
                    enumerable: value,
                    configurable: true
                });
            }
        }
    }
    

    the factory is only needed so I could use @include(false) instead of @exclude.

    Only drawback is the fact that the property now tied to the prototype, hence normal JSON.stringify(instance) would not serialize it.

    On that note, we can further make a generic decorator usable both in property and method, as such:

    //method decorator
    function excludeMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        descriptor.enumerable = false;
        return descriptor;
    };
    //property decorator
    function excludeProperty(target: any, propertyKey: string): any {
        // Buffer the value
        var _val = target[propertyKey];
        // Delete property.
        if (delete target[propertyKey]) {
            // Create new property with getter and setter
            Object.defineProperty(target, propertyKey, {
                get: () => _val,
                set: (newVal) => _val = newVal,
                enumerable: false,
                configurable: true
            });
        }
    }
    function exclude(...args : any[]) {
        switch(args.length) {
            case 2:
                return excludeProperty.apply(this, args);
            case 3:
                if (typeof args[2] !== "number")
                    return excludeMethod.apply(this, args);
            default:
                throw new Error("Decorators are not valid here!");
        }
    }
    

    so now we can use it as such:

    class MyClass {
        test: string = "test";
        @exclude
        testExclude: string = "should be excluded";
        get enumerated(): string {
            return "yes";
        }
        @exclude
        get nonEnumerated(): string {
            return "non enumerable"
        }
        constructor() {}
    }
    
    let x = new MyClass();
    //to serialize, we have to whitelist the instance and its prototype prop keys
    console.log(JSON.stringify(x, Object.keys(x).concat(Object.keys(MyClass.prototype))));
    

    So far I haven't found a cleaner way to do this.