Reputation: 512
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
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
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