JSON.stringify() Will Recursively Call toJSON() If It Exists During Serialization In JavaScript
In JavaScript (and Node.js), the JSON global object provides a means of serializing and parsing data values for transportation and storage. In the past, I've looked at using the JSON.parse() reviver option in order to hydrate Date strings; but, I've never really played around with the JSON.stringify() method. When you call JSON.stringify(), the serialization algorithm will look for a .toJSON() method on the given object. And, as it turns out, it will do this recursively as it traverses the object graph of the value being serialized.
According to the Mozilla documentation, when you serialize a value with JSON.stringify(), the default behavior is to iterate over the object's iterable properties (if it's a complex object) and generate a String-based representation of the value. If, however, the given value has a .toJSON() property - and that property is a Function instance - JSON.stringify() will call that method and then serialize the resultant value in lieu of the original value.
I wasn't sure if this behavior only applied to the value being passed into the JSON.stringify() method; or, if this would apply to nested values within the object graph. To experiment with this behavior, I put together a small demo that nested several objects with custom .toJSON() methods; and, I housed those objects within a plain-old JavaScript object:
// Require the core node modules.
var chalk = require( "chalk" );
// Create some short-hand log styles.
var dim = chalk.dim;
var red = chalk.red;
var bold = chalk.red.bold;
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
class AccountCollection {
constructor() {
this._items = [];
}
// ---
// PUBLIC METHODS.
// ---
add( account ) {
this._items.push( account );
// Return "this" reference for fluent API usage.
return( this );
}
// I provide a serialization hook for JSON.stringify().
toJSON() {
console.log( dim( "Calling AccountCollection.toJSON()." ) );
// NOTE: We don't have to explicitly serialize the value that is being
// returned. Instead, we just have to return the value that we want to be
// serialized - JSON.stringify() will take care of the serialization it.
return( this._items.slice() );
}
}
class Account {
constructor( id, name, password ) {
this._id = id;
this._name = name;
this._password = password;
}
// ---
// PUBLIC METHODS.
// ---
// I provide a serialization hook for JSON.stringify().
toJSON() {
console.log( dim( "Calling Account.toJSON()." ) );
// Notice that the plain-old JavaScript Object (POJO) that we return does
// NOT contain the PASSWORD. We don't want that piece of information to leave
// the system boundary. As such, it shouldn't need to be included in the
// serialization process / result.
return({
id: this._id,
name: this._name
// NO PASSWORD FIELD !
});
}
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
var accounts = new AccountCollection()
.add( new Account( 1, "Kim", "r2d2" ) )
.add( new Account( 2, "Sarah", "icanhazkittens" ) )
.add( new Account( 3, "Libby", "password3" ) )
;
// Here, we are creating an object whose nested values are objects that contain special
// directives for JSON serialization. JSON.stringify() will recurse through this object
// graph, calling the optional .toJSON() method on all values (if it exists).
var response = {
data: accounts,
status: "OK"
};
console.log( red( "Serializing response using", bold( "JSON.stringify()" ) + "." ) );
console.log( JSON.stringify( response, null, 4 ) );
As you can see, both the AccountCollection and the Account classes have custom .toJSON() instance methods. The AccountCollection just returns the underlying Array when asked to serialize. But, the Account class uses the .toJSON() hook as an opportunity to sanitize some of the data that is going to be passed beyond the boundary of the system (removing "password" from the result). Ultimately, however, neither of these classes represent the top-level value being serialized - both exist within some sort of API response. Yet, when we run the above code, we get the following console output:
As you can see, the JSON.stringify() method started at the top-level API response object and the recursed down through the object graph. And, not only did it call .toJSON() on the AccountCollection, it then called .toJSON() on all of the Account objects returned by the AccountCollection.toJSON() invocation.
I am not sure how I feel about an object knowing how to serialize itself. That strikes me as an overstepping of concerns. But, regardless, it's good to have a better mental model for how JSON (JavaScript Object Notation) serialization works.
Want to use code from this post? Check out the license.
Reader Comments
Thanks for pointing this out Ben. Can you please revise your article with a link yo the Mozilla documentation that you found?
@All,
Here's another look at the JSON.stringify() method, this time looking at the "replacer" method which, it turns out, is almost perfect for preparing data for logging:
www.bennadel.com/blog/3278-using-json-stringify-replacer-function-to-recursively-serialize-and-sanitize-log-data.htm
@Jason,
No problem, take a look here:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#toJSON()_behavior