Evan
Evan

Reputation: 512

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?

Upvotes: 1

Views: 763

Answers (2)

Evan
Evan

Reputation: 512

Down the rabbit hole I fell...

so for some reason adding whitelist to JSON.stringify somehow made it not recursively serialize nested objects:

class a {
    p1 = 1;
    p2 = 2;
}
class b {
    m1 = new a();
    m2 = "test";
    m3 = new Array<a>();
}
let i = new b();
i.m3.push(new a());
i.m3.push(new a());

JSON.stringify(i); 
// properly gives 
// {"m1":{"p1":1,"p2":2},"m2":"test","m3":[{"p1":1,"p2":2},{"p1":1,"p2":2}]}

JSON.stringify(i, Object.keys(i).concat(Object.keys(Object.getPrototypeOf(i))));
// nested class a doesn't get serialized 
// {"m1":{},"m2":"test","m3":[{},{}]}

So just putting this out there, if you're like me and want to hide private variables in TS and giving it a readonly facade property:

declare it as a simple object member, then modify its propertyDescriptor in constructor as such:

//Don't do this
class a {
    private _prop;
    get prop() { return _prop; }
}

//do this instead
class a {
    prop; //just define your public-facing property
    constructor() {
        let _prop; //internal variable here
        Object.defineProperty(this, "prop", { //now we modify the existing prop, 
            get: () =>  _prop, //closure to outside variable 
            //(no set here, it's readonly after all)
            enumerable: true, //make sure it's visible
            configurable: false //close up access
        }); 
    }
}

now we can simply use JSON.stringify(instance). The only drawback is if you have complicated getter/setter, keep in mind that this is invoked in every instance/new.

with this pattern and @exclude decorators above, pretty much solves my use case. Hope this helps someone..

Upvotes: 0

Evan
Evan

Reputation: 512

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.

Upvotes: 1

Related Questions