Easy serialization through ES7/Typescript annotations
This is a library to make serializing and deserializing complex JS objects a breeze. It works by applying meta data annotations (as described in ES7 proposal and experimental Typescript feature) to fields in a user defined class.
var pet = new Pet('Cracker', 'Cat');
var person = new Person('Matt', new Date(1989, 4, 3), 'coding', pet);
var json = Serialize(person);
/* json = {
name: 'Matt',
birthdate: 'Wed May 03 1989 00:00:00 GMT-0400 (EDT)',
favorite_hobby: 'coding',
'favorite_pet': {
Name: 'Cracker',
animalType: 'Cat',
hobby: 'laser pointers'
}
}
*/
import { serialize, serializeAs } from 'cerialize';
class Pet {
//keys can be customized using serializeAs(string)
@serializeAs('Name') public name : string;
@serialize animalType : string;
constructor(name : string, animalType : string) {
this.name = name;
this.animalType = animalType;
}
//this callback runs after the object is serialized. JSON can be altered here
public static OnSerialized(instance : Pet, json : any) : void {
json['addiction'] = 'laser pointers';
}
}
class Person {
//primitive properties marked with @serialize will be serialized as is
@serialize public name : string;
//complex types like Date or a user defined type like `User` use the serializeAs(keyNameOrType, keyName?) construct
@serializeAs(Date) public birthdate : Date;
//serialize key name as `favorite_hobby` instead of `hobby`
@serializeAs('favorite_hobby') public hobby : string;
//serialize the key name as `favorite_pet` and treat it like a `Pet`
@serializeAs(Pet, 'favorite_pet') public pet : Pet;
public firstName : string; //things not marked with an annotation are not serialized
constructor(name : string, birthdate : Date, hobby : string, pet : Pet) {
this.name = name;
this.firstName = name.split(' ')[0];
this.birthdate = birthdate;
this.hobby = hobby;
this.pet = pet;
}
}
After defining which properties should be serialized, deserialized, or both, the actual marshalling is handled by a trio of simple functions.
-
Serialize(value, classType?)
takes in a value and spits out a serialized value using the algorithm described in Serializing Objects -
Deserialize(rawObject, classType)
takes an untyped js object or array and a class type to deserialize it into and returns a new instance ofclassType
with all the deserialized properties fromrawObject
using the algorithm described in Deserializing Objects -
DeserializeInto(rawObject, instance)
takes an untyped js object or array and an instance to populate with the new data, reusing any fields that are reference types and already exist oninstance
where possible and creating the fields where not. This is described in detail in Deserializing Into Existing Objects
Calling Serialize(value, classType?)
on something will serialize it into a pre-stringified json object. You must call JSON.stringify
to make it a string. Serialization works through the following alorithm:
-
If
value
is an array, all items in the array will haveSerialize
called on them (withclassType
argument if given). -
If
classType
is given, thevalue
is considered like an instance of this object class (see3
and4
). -
If
value
is an object that has any properties marked with a serialization annotation, or inherits any properties marked for serialization, only those properties marked for serialization will be serialized. Anything without an annotation will not haveSerialize
called on them. -
If
value
is an object that does not have any properties marked for serialization and does not inherit any properties marked for serialization, all keys in that object will be serialized as primtives, unless the value at a given key is an instance of a class with serialized properties, in which case it will be serialized as described above in 2. -
If
value
is a primitive, it will be returned as is. -
If
value
isundefined
,Serialize
will returnnull
.
import { serialize, Serialize } from 'cerialize';
class Product {
// Will be serialized as is
@serialize public name : string;
// Will not be serialized
public sku : string;
constructor(name : string, sku : string) {
this.name = name;
this.sku = sku;
}
}
var product = new Product('47Z Phone', '47Z-S');
var serializedProduct = Serialize(product);
console.log(JSON.stringify(serializedProduct)); // Will display : {name: '47Z Phone'}
var productJson = {name: '47Z Phone', sku: '47Z-S'};
var serializedProductJson = Serialize(productJson);
console.log(JSON.stringify(serializedProductJson)); // Will display : {name: '47Z Phone', sku: '47Z-S'}
var serializedProductJsonToObject = Serialize(productJson, Product);
console.log(JSON.stringify(serializedProductJsonToObject)); // Will display : {name: '47Z Phone'}
The simplest way to deserialize a piece of JSON is to call Deserialize(json, type)
on it. This function takes the provided type and pulls out all the properties you tagged with @deserialize
, @deserializeAs(keyNameOrType, keyName?)
, @autoserialize
or @autoserializeAs(keyNameOrType, keyName?)
and will pump them (recursively) into a new instance of type
which is returned. If your type marks a property for deserialization that is itself tagged with deserialization annotations, that property will be hydrated into it's type following the same deserialization algorithm.
class Tree {
@deserialize public species : string;
@deserializeAs(Leaf) public leafs : Array<Leaf>; //arrays do not need extra specifications, just a type.
@deserializeAs(Bark, 'barkType') public bark : Bark; //using custom type and custom key name
@deserializeIndexable(Leaf) public leafMap : {[idx : string] : Leaf}; //use an object as a map
}
class Leaf {
@deserialize public color : string;
@deserialize public blooming : boolean;
@deserializeAs(Date) public bloomedAt : Date;
}
class Bark {
@deserialize roughness : number;
}
var json = {
species: 'Oak',
barkType: { roughness: 1 },
leafs: [ {color: 'red', blooming: false, bloomedAt: 'Mon Dec 07 2015 11:48:20 GMT-0500 (EST)' } ],
leafMap: { type1: { some leaf data }, type2: { some leaf data } }
}
var tree = Deserialize(json, Tree);
It is also possible to re-use existing objects when deserializing with DeserializeInto(json, Type, target)
. You might want to do this so that you can maintain references to things even after updating their properties. This is handled exactly the same way as Deserialize(json, Type)
except that it takes one additional argument, the object you want to deserialize properties into. If the target instance you provide is null or undefined, this behaves identically to Deserialize
.
//reusing the above class and json structures
var localTree = new Tree();
var leaf = new Leaf();
leaf.color = 'blue';
localTree.leafMap = { type1: new Leaf(), type2: new Leaf() }
localTree.leafs[0] = leaf;
DeserializeInto(json, Tree, localTree)
expect(localTree.leafs[0]).toEqual(leaf) //true, the leaf instance was reused but has a differnt color
expect(localTree.leafs[0].color).toEqual('red'); //red comes from the json defined earlier
expect(localTree.leafMap['type1']).color).toEqual('red') //this is how `@xxxIndexable` works
If you want the same behavior for a property when serializing and deserializing, you can either tag that property with a @serialize
and @deserialize
(or their As
variants) or you can use @autoserialize
and @autoserializeAs(keyNameOrType, keyName?)
which will do this in a single annotation and behave exactly the same as @serialize
and @deserialize
. @autoserializeIndexable
(and friends) will retain type information while allowing an object to be used as a dictionary, without this the system would treat your input object as whatever type you provide instead of a map of objects of that type.
A callback can be provided for when a class is serialized and / or deserialized. To define the callback, add a static method OnSerialized(instance : any, json : any)
to the class that needs custom post processing. Continuing with the Tree example from before, lets say your server expects a zero indexed roughness value but your front end needs to use a 1 based roughness. This can be handled with OnSerialized
and OnDeserialized
trivially.
class Bark {
public static OnSerialized(instance : Bark, json : any) : void {
json.roughness--;
}
public static OnDeserialized(instance : Bark, json : any) : void {
instance.roughness++;
}
}
Serialization behavior is not inherited by subclasses automatically. To inherit a base class's serialization / deserialization behavior, tag the subclass with @inheritSerialization(ParentClass).
import { inheritSerialization } from 'cerialize';
@inheritSerialization(User)
class Admin extends User {
}
Typescript generics unfortunately do not give any runtime type information, but they are still helpful in that you do not need to cast the output of a Deserialize
function to a given type when the type can be inferred by the compiler. Cerialize supports generics through GenericDeserialize
and GenericDeserializeInto
. These two functions work exactly the same as their non generic counterparts but have a typed signature.
import { GenericDeserialize, GenericDeserializeInto } from 'cerialize';
var tree = GenericDeserialize({value: "someValue"}, Tree);
expect((tree instanceof Tree)).toBe(true);
expect(tree.value).toBe("someValue");
var tree = new Tree();
tree.value = 'hello';
var tree2 = GenericDeserializeInto({value: "someValue"}, Tree, tree);
expect((tree2 instanceof Tree)).toBe(true);
expect(tree2).toBe(tree);
expect(tree.value).toBe("someValue");
Sometimes you want to define a function to do the serializing for you and not rely on annotations. One example might be a type represented as an object on the server but as a primitive on the client. You can define an object with the keys Serialize
and Deserialize
which are functions you can define to handle these operations.
const MoneySerializer = {
Serialize(json : any) : any {
return { amount: value, currency: "EUR" };
},
Deserialize(json : any) : any {
return parseFloat(json.amount);
}
};
class CustomThing {
@autoserializeAs(MoneySerializer) public amount : number;
}
Often your server and your client will have different property naming conventions. For instance, Rails / Ruby generally expects objects to have properties that are under_score_cased while most JS authors prefer camelCase. You can tell Cerialize to use a certain key transform automatically when serializing and deserializing by calling DeserializeKeysFrom(transform : (key : string) => string)
and SerializeKeysTo(transform : (key : string) => string)
. A handful of transform functions are provided in this package or you can define your own function conforming to (key : string) => string
.
import {SerializeKeysTo, DeserializeKeysFrom, UnderscoreCase} from 'cerialize';
//CamelCase, UnderscoreCase, SnakeCase, and DashCase are provided
SerializeKeysTo(UnderscoreCase);
DeserializeKeysFrom(UnderscoreCase);
Cerialize uses the ES6 Map implementation so you must be on a browser that supports it or include a shim.