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>)
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...
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 }
}
}
With that update, the stringified result will be :
{
"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 :
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 :
npm install @badcafe/jsonizer
import { Reviver } from '@badcafe/jsonizer';
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
}
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
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 hostPerson
. - Unfortunately, the
reviver
function is applied byJSON.parse()
bottom-up, that is to say everyHobby
instance is supposed to be revived before its hostPerson
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
}
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}[]
}
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)
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);
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)