DEV Community

Cover image for How to stringify and parse a graph
Philippe Poulard
Philippe Poulard

Posted on

How to stringify and parse a graph

JSON.stringify() (and JSON.parse()) is working well for tree structures; in fact, it doesn't work as-is for graphs.

Let's see it in action (with relevant outputs from the console in the comments) :

const a = {}
const b = {}
b.a = a // same as b={a}
a.b = b
// <ref *1> { a: { b: [Circular *1] } }
const json = JSON.stringify(a);
// Uncaught TypeError: Converting circular structure to JSON
//    --> starting at object with constructor 'Object'
//    |     property 'b' -> object with constructor 'Object'
//    --- property 'a' closes the circle
//    at JSON.stringify (<anonymous>)
Enter fullscreen mode Exit fullscreen mode

We clearly set a.b = b and b.a = a which leads to an infinite loop when traversing the graph. Fortunately, this is detected by JSON.stringify() !

Of course, there are existing tools to inspect a graph of objects, but the purpose of JSON is to exchange a tree of objects, not a graph. Typically, you create some data structure server-side, you stringify it, then send the JSON to the client that can parse it.

Let's go on with some realistic data ; we are using Typescript to have clean data types, but it will work identically with Javascript :

class Person {
    hobbies: Hobby[] = []
    constructor(
        public firstName: string,
        public birthDate: Date
   ) {}
}

class Hobby {
    constructor(
        public name: string,
        public person: Person
    ) {
        person.hobbies.push(this);
    }
}

const bob = new Person('Bob', new Date('1998-12-20'));
new Hobby('cooking', bob);
new Hobby('programming', bob);

const personJson = JSON.stringify(bob);
// TypeError: Converting circular structure to JSON...
Enter fullscreen mode Exit fullscreen mode

There are two things to fix : not only we expect to get a clean JSON string, but we also expect to get back that graph of instances after using JSON.parse().

Basically, we need one recipe to stringify, and another recipe to revive, one being the opposite of the other.

JSON.stringify()

If we want to turn our graph to a tree, we must get rid of circular references, that implies that we must decide which data is hosting the other. In our case, it is clear that a person has hobbies: Person is left as-is.

Then, we have to fix things in the subordinate class Hobby, which can be made in various ways :

  • Customize .toJSON()
  • Auto-discard the unwanted field

Customize .toJSON()

Just return the fields that you want to have in the result JSON :

class Hobby {
    constructor(
        public name: string,
        public person: Person
    ) {
        person.hobbies.push(this);
    }
    toJSON() {
        return { name: this.name }
    }
}
Enter fullscreen mode Exit fullscreen mode

With that update, the stringified result will be :

{
    "firstName": "Bob",
    "birthDate": "1998-12-20T00:00:00.000Z",
    "hobbies": [
        { "name": "cooking" },
        { "name": "programming" }
    ]
}
Enter fullscreen mode Exit fullscreen mode

A variant could be toJSON() { return this.name }, that would give "hobbies": [ "cooking", "programming" ]

Auto-discard the unwanted field

We can either make the field non enumerable, or use a Symbol, like shown below :

const PERSON: unique symbol = Symbol();
class Hobby {
    [PERSON]: Person
    constructor(
        public name: string,
        person: Person
    ) {
        this[PERSON] = person;
        person.hobbies.push(this);
    }
}
Enter fullscreen mode Exit fullscreen mode

Of course, the stringified result will be the same.

JSON.parse()

Getting back a tree or a graph of classes instances is not as obvious as you may think, since the reviver argument of JSON.parse(data, reviver) is a function that is not aware of the hierarchy each time it is invoked, and there are many corner cases to take care of.

Fortunately, I wrote a library that does the job simply ; let's use it :

npm install @badcafe/jsonizer
Enter fullscreen mode Exit fullscreen mode
import { Reviver } from '@badcafe/jsonizer';
Enter fullscreen mode Exit fullscreen mode

In a nutshell, @badcafe/jsonizer let you define revivers contextually. For a given structure, you describe in a plain Javascript object the expected mappings, plus the recipe that allow to create new instances (this latter is bound to the 'self' familiar key '.'). Then that object may be bound to a class thanks to a decorator, or applied as a normal function to a class.

You are lost ? Let's see some code with a reviver defined as a decorator :

@Reviver<Hobby>({
    // '.' is the 'self' entry,
    //      that tells how to create new Hobby instance
    '.': ({name, person}) => new Hobby(name, person) // 💥
})
class Hobby {
    // same code as shown previously
}
Enter fullscreen mode Exit fullscreen mode

Then a reviver defined as a normal function

Reviver<Person>({
    // '.' is the 'self' entry,
    //      that tells how to create new Person instance    
    '.': ({firstName, birthDate}) => new Person(firstName, birthDate),
    // then, the fields that require a mapping
    birthDate: Date, // bound to a Date
    hobbies: { // bound to a submapping
        // '*' is the familiar 'any' key for any array item
        '*': Hobby // bound to a Hobby
    }
})(Person) // bound the reviver to the class
Enter fullscreen mode Exit fullscreen mode

If you have some difficulties to understand everything so far, you may read another post that explains more progressively how all that is working.

So far so good... in fact, not really :

  • If we examine again how our classes are defined, we understand that a Hobby can be created after having created a host Person.
  • Unfortunately, the reviver function is applied by JSON.parse() bottom-up, that is to say every Hobby instance is supposed to be revived before its host Person instance !

There is clearly some chicken 🐔 and egg 🥚 issue here...

Worse 💥, you also may have noticed that the builder function of the hobby, that is to say : '.': ({name, person}) => new Hobby(name, person) was wrong, because the JSON string of a hobby is made just of a name without a person, like this : { "name": "cooking" }, therefore, it is normal that it doesn't work...

The fix

To fix this issue, we understand that we don't have on that builder a person instance, therefore we will supply it later.

So, instead of building an instance of Hobby, we will build a factory. In order to be compliant with the JSON source structure, we create a source type that describe it :

// describing the JSON structure 
// will prevent a wrong usage of the person field
type HobbyDTO = { name: string }

// the type arguments of Reviver() are <Target,Source>
//                              (when omitted, Source=Target)
@Reviver<Hobby, HobbyDTO>({
    // return a factory that takes a person argument
    '.': ({name}) => (person: Person) => new Hobby(name, person)
})
class Hobby {
    // same code as shown previously
}
Enter fullscreen mode Exit fullscreen mode

As a consequence, we have somewhat inserted an intermediate structure in the flow ; let's define a type for it :

type PersonDTO = {
    firstName: string,
    birthDate: Date,
    // an array of Hobby factories
    hobbies: {(person: Person): Hobby}[]
}
Enter fullscreen mode Exit fullscreen mode

If there were too much fields to copy, just focus on the replacement expected, like this :

type PersonDTO = Omit<Person, 'hobbies'> & {
    hobbies: {(person: Person): Hobby}[]
}

Then fix the reviver of the Person class accordingly :

Reviver<Person, PersonDTO>({
    '.': ({firstName, birthDate, hobbies}) => {
        const person = new Person(firstName, birthDate);
        // then apply the person to the factories
        hobbies.forEach(hobby => hobby(person))
        return person;
    },
    birthDate: Date,
    hobbies: {
        '*': Hobby
    }
})(Person)
Enter fullscreen mode Exit fullscreen mode

Job done ! You just need to parse the JSON to revive your graph of object instances :

const personJson = await read('person.json');
const personReviver = Reviver.get(Person);
const person = JSON.parse(personJson, personReviver);
Enter fullscreen mode Exit fullscreen mode

As a bonus, with Typescript the person const result of the parsing is a typed data (its type is Person).

See also :

Top comments (0)