Prasanth Janardhanan

How to load from a JSON file to Javascript class object (Javascript/Typescript Serialization)

It is a common requirement to load a nested Javascript class from a JSON file. Sometimes you want to send the JSON across a network and load it back to a Javascript class.

For better clarity, let us understand the difference between a Javascript class instance and an Object. Take this class for example:

This is a class that draws a rectangle on the canvas

class Rectangle{
    public x:number=0
    public y:number=0
    public width:number=0
    public height:number=0
    
    public draw(ctx:CanvasRenderingContext2D)
    {
        ctx.strokeStyle = this.stroke
        ctx.rect(this.x,this.y, this.width, this.height)
        ctx.stroke();
    }
        
    public area()
    {
        return this.width * this.height           
    }
}

Suppose you have an instance of this rectangle and that you want to save the rectangle to a file. The easy solution would be to use JSON.stringify(rect). Once the object is converted into JSON string, it is easy to save to a file or send through wire to be saved to a database.

const rect = new Rectangle(10,20,120,110)
const str = JSON.stringify(rect)
// str will be this:
// {"x":10,"y":20,"width":120,"height":110,"stroke":"#999"}

However, how will you load the rectangle back from persistence?

You can use JSON.parse() to parse the string to an object like this:

const str = '{"x":10,"y":20,"width":120,"height":110,"stroke":"#999"}'
const obj = JSON.parse(str)
//obj will be a plain object like this:
/*
{ 
    x: 10, 
    y: 20, 
    width: 120, 
    height: 110, 
    stroke: '#999' 
}
*/

However, you can’t call obj.draw() or obj.area() because obj is a plain Javascript object, not an instance of Rectangle()

You can create and load Rectangle() from the plain object:

const rect = new Rectangle()
rect.x = obj.x
rect.y = obj.y
rect.width = obj.width
rect.height = obj.height

This solution can work; but it can become too verbose as you add more shapes and constructs that contain other shapes.

Enter serialization

The module class-transformer makes it easier to serialize nested class objects, and arrays in to a string. Later, you can reconstruct the same nested structures from the string.

Let us convert a JSON string to a Rectangle instance:

import { deserialize } from 'class-transformer';

const str = '{"x":10,"y":20,"width":100,"height":110,"stroke":"#999"}'

const rect = deserialize(Rectangle, str)

expect(rect.area()).toBe(11000)

You can transform from plain object to class object as well. Example below:

import { plainToClass } from 'class-transformer';

const obj = {
    x:20,
    y:10,
    width:100,
    height:110
};

const rect = plainToClass(Rectangle, obj)
expect(rect.area()).toBe(11000)
expect(rect.hitTest(20,30)).toBe(true)
expect(rect.hitTest(19,30)).toBe(false)

Nested structures

When one object contains another object, the contained objects also can be serialized. Use the @Type() decorator.

export class Rectangle extends Shape{
    
    public x:number=0
    public y:number=0
    public width:number=0
    public height:number=0
    
    @Type(()=>StrokeStyle)
    public stroke:StrokeStyle=new StrokeStyle()
}

Excluding certain attributes

The canvas and context objects of the Drawing class are runtime only and we don’t want to save those. In order to exclude those members, use the @Exclude() decorator.

import { Exclude } from 'class-transformer';

export class Drawing {
    @Exclude()
    private canvas: HTMLCanvasElement|null =  null;
    
    @Exclude()
    private context: CanvasRenderingContext2D|null=null;
}

Serializing Different types of objects in an array

We have different types of drawing objects - like Circle, Rectangle, Line etc. All the objects inherit from an abstract class Shape. This works well for drawing making use of the polymorphic features.

export abstract class Shape
{
    abstract draw(ctx:CanvasRenderingContext2D):void;
}

export class Drawing {
    public shapes: Shape[]= []
    
    public draw(){
        for(let shape of this.shapes)
        {
            shape.draw(this.context)
        }
    }    
}

But, how do we save all the different types of objects in the shapes array? class-transformer has a discriminator feature precisely for this purpose.

Here is how we can do it:

export class Drawing {
   
   
    @Type(()=>Shape, {
        discriminator: {
            property: 'type',
            subTypes: [
                { value: Line, name: 'line' },
                { value: Rectangle, name: 'rectangle' },
                { value: Circle, name: 'circle' },
          ],
        },
    })
    public shapes: Shape[]= []
    
   
}

The @Type() decorator can access a discriminator option. The property indicates the class of the object. Then we provide the class names and their corresponding types.

Here is the serialized JSON of a Drawing:

{
  "shapes": [
    {
      "x": 30,
      "y": 20,
      "width": 100,
      "height": 200,
      "stroke": {
        "width": 2,
        "color": "#999"
      },
      "type": "rectangle"
    },
    {
      "x1": 30,
      "y1": 40,
      "x2": 140,
      "y2": 150,
      "stroke": "#999",
      "type": "line"
    },
    {
      "x": 100,
      "y": 150,
      "radius": 30,
      "stroke": "#999",
      "type": "circle"
    }
  ]
}

We can now deserialize to a drawing object and call the member functions of the Drawing class. Like so:

const sd = deserialize(Drawing, str)

expect(sd.shapes[0].area()).toBe(20000); // 100 * 200

Writing Custom Decorators

As more objects are added and each having a large set of customizable attributes, the JSON becomes too complex and the attributes redundant. For example, check out this drawing:

  let d = new Drawing('canvas');
  
  d.addShape(new Rectangle(10,10,200,300))
  d.addShape(new Rectangle(30,10,300,150))
  d.addShape(new Rectangle(20,50,120,110))
  
  const sd = serialize(d)
  console.log("drawing serialized ", sd)
  /**
 {"shapes":[
     {"x":10,"y":10,"width":200,"height":300,
     "stroke":{"width":1,"color":"#999"},
     "type":"rectangle"},
     
     {"x":30,"y":10,"width":300,"height":150,
     "stroke":{"width":1,"color":"#999"},
     "type":"rectangle"},
     
     {"x":20,"y":50,"width":120,"height":110,
     "stroke":{"width":1,"color":"#999"},
     "type":"rectangle"}
     
]}
  */

Notice that the stroke class has default values saved and that makes the JSON export too large with redundant values. One way to avoid this redundancy is to skip saving the default values. However, there is no feature at the moment to skip the default value. The good news is that adding a custom decorator is easy.

Let us add an ExcludeDefault() decorator:


export function ExcludeDefault(
    default_value:string|number|boolean,
    options: TransformOptions = {}
  ) 
  {
    
    return Transform(({value,type})=>
          {
            if(type == TransformationType.CLASS_TO_PLAIN){
              if(value === default_value){
                return undefined;
              }
            }
            return value;
          }, options)
  }

ExcludeDefault is really a thin wrapper around the Transform() decorator that class-transformer provides. It checks the value is default value itself and then skips saving it if default.

Let us now use this custom decorator to save the drawing above.

import { ExcludeDefault } from './exclude'

export default class StrokeStyle{
    @ExcludeDefault(1)
    public width:number=1;
    
    @ExcludeDefault('#999')
    public color:string="#999";
   
}

Then if we serialize the drawing, you can see that the generated JSON is much more simplified:

  let d = new Drawing('canvas');
  
  d.addShape(new Rectangle(10,10,200,300))
  d.addShape(new Rectangle(30,10,300,150))
  d.addShape(new Rectangle(20,50,120,110))
  
  const sd = serialize(d)
  console.log("drawing serialized ", sd)
  /**
     {"shapes":[
        {"x":10,"y":10,"width":200,"height":300,"stroke":{},"type":"rectangle"},{"x":30,"y":10,"width":300,"height":150,"stroke":{},"type":"rectangle"},
        {"x":20,"y":50,"width":120,"height":110,"stroke":{},"type":"rectangle"}
        ]
    }
  */

As you can see, it is not very difficult to define custom decorators. Decorators can be used to define custom object models that fit the requirements of your application. Most of the time, Transform() itself can be used for the data transformations.