How to stringify and parse a graph
Tags: javascript, typescript, json, jsonizer
Published: 2022-02-21 18:27:56

`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) :

```javascript
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>)
```

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 : 

```typescript
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...
```

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 :

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

With that update, the stringified result will be :

```json
{
    "firstName": "Bob",
    "birthDate": "1998-12-20T00:00:00.000Z",
    "hobbies": [
        { "name": "cooking" },
        { "name": "programming" }
    ]
}
```

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

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

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 :

```bash
npm install @badcafe/jsonizer
```

```typescript
import { Reviver } from '@badcafe/jsonizer';
```

In a nutshell, [`@badcafe/jsonizer`](https://badcafe.github.io/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 :

```typescript
@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
}
```

Then a reviver defined as a normal function

```typescript
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
```

> If you have some difficulties to understand everything so far, you may read [another post that explains more progressively how all that is working](https://dev.to/ppoulard/reviving-json-classes-made-easy-58in).

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 :

```typescript
// 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
}
```

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

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

> If there were too much fields to copy, just focus on the replacement expected, like this :
> ```typescript
> type PersonDTO = Omit<Person, 'hobbies'> & {
>     hobbies: {(person: Person): Hobby}[]
> }
> ```

Then fix the reviver of the `Person` class accordingly :

```typescript
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)
```

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

```typescript
const personJson = await read('person.json');
const personReviver = Reviver.get(Person);
const person = JSON.parse(personJson, personReviver);
```

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

> See also :
> - [Reviving JSON classes made easy](https://dev.to/ppoulard/reviving-json-classes-made-easy-58in)
> - [How to fix a bad JSON structure](https://dev.to/ppoulard/how-to-fix-a-bad-json-structure-lhh)