JSON.stringify deep objects
Here is a function that respects the built-in JSON.stringify() rules while also limiting depth. This version handles cyclical references by making them either null, or using an optional callback to get an object ID (such as a GUID).
function stringify(val, depth, replacer, space, onGetObjID) {
depth = isNaN(+depth) ? 1 : depth;
var recursMap = new WeakMap();
function _build(val, depth, o, a, r) { // (JSON.stringify() has it's own rules, which we respect here by using it for property iteration)
return !val || typeof val != 'object' ? val
: (r = recursMap.has(val), recursMap.set(val,true), a = Array.isArray(val),
r ? (o=onGetObjID&&onGetObjID(val)||null) : JSON.stringify(val, function(k,v){ if (a || depth > 0) { if (replacer) v=replacer(k,v); if (!k) return (a=Array.isArray(v),val=v); !o && (o=a?[]:{}); o[k] = _build(v, a?depth:depth-1); } }),
o===void 0 ? (a?[]:{}) : o);
}
return JSON.stringify(_build(val, depth), null, space);
}
var o = {id:'SOMEGUID',t:true};
var value={a:[12,2,{y:3,z:{o1:o}}],s:'!',b:{x:1,o2:o,o3:o}};
console.log(stringify(value, 0, (k,v)=>{console.log('key:'+k+';val:',v); return v}, 2));
console.log(stringify(value, 1, (k,v)=>{console.log('key:'+k+';val:',v); return v}, 2));
console.log(stringify(value, 2, (k,v)=>{console.log('key:'+k+';val:',v); return v}, 2));
console.log(stringify(value, 3, (k,v)=>{console.log('key:'+k+';val:',v); return v}, 2));
console.log(stringify(value, 4, (k,v)=>{console.log('key:'+k+';val:',v); return v}, 2, (v)=>{return v.id}));
{}
{
"a": [
12,
2,
{}
],
"s": "!",
"b": {}
}
{
"a": [
12,
2,
{
"y": 3,
"z": {}
}
],
"s": "!",
"b": {
"x": 1,
"o2": {},
"o3": null
}
}
{
"a": [
12,
2,
{
"y": 3,
"z": {
"o1": {}
}
}
],
"s": "!",
"b": {
"x": 1,
"o2": null,
"o3": null
}
}
{
"a": [
12,
2,
{
"y": 3,
"z": {
"o1": {
"id": "SOMEGUID",
"t": true
}
}
}
],
"s": "!",
"b": {
"x": 1,
"o2": "SOMEGUID",
"o3": "SOMEGUID"
}
(taken from my post here https://stackoverflow.com/a/57193068/1236397)
Here is a TypeScript version:
/** A more powerful version of the built-in JSON.stringify() function that uses the same function to respect the
* built-in rules while also limiting depth and supporting cyclical references.
*/
export function stringify(val: any, depth: number, replacer: (this: any, key: string, value: any) => any, space?: string | number, onGetObjID?: (val: object) => string): string {
depth = isNaN(+depth) ? 1 : depth;
var recursMap = new WeakMap();
function _build(val: any, depth: number, o?: any, a?: boolean, r?: boolean) {
return !val || typeof val != 'object' ? val
: (r = recursMap.has(val),
recursMap.set(val, true),
a = Array.isArray(val),
r ? (o = onGetObjID && onGetObjID(val) || null) : JSON.stringify(val, function (k, v) { if (a || depth > 0) { if (replacer) v = replacer(k, v); if (!k) return (a = Array.isArray(v), val = v); !o && (o = a ? [] : {}); o[k] = _build(v, a ? depth : depth - 1); } }),
o === void 0 ? (a?[]:{}) : o);
}
return JSON.stringify(_build(val, depth), null, space);
}
Note: Arrays are treated like strings - an array of primitive values; thus, any nested object items are treated as the next level instead of the array object itself (much like how a string can be an array of characters, but is one entity).
Update: Fixed a bug where empty arrays rendered as empty objects.
I did what I initially feared I'll have to do : I took Crockford's code and modified it for my needs. Now it builds JSON but handles
- cycles
- too deep objects
- too long arrays
- exceptions (accessors that can't legally be accessed)
In case anybody needs it, I made a GitHub repository : JSON.prune on GitHub
Here is the code :
// JSON.pruned : a function to stringify any object without overflow
// example : var json = JSON.pruned({a:'e', c:[1,2,{d:{e:42, f:'deep'}}]})
// two additional optional parameters :
// - the maximal depth (default : 6)
// - the maximal length of arrays (default : 50)
// GitHub : https://github.com/Canop/JSON.prune
// This is based on Douglas Crockford's code ( https://github.com/douglascrockford/JSON-js/blob/master/json2.js )
(function () {
'use strict';
var DEFAULT_MAX_DEPTH = 6;
var DEFAULT_ARRAY_MAX_LENGTH = 50;
var seen; // Same variable used for all stringifications
Date.prototype.toPrunedJSON = Date.prototype.toJSON;
String.prototype.toPrunedJSON = String.prototype.toJSON;
var cx = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,
escapable = /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,
meta = { // table of character substitutions
'\b': '\\b',
'\t': '\\t',
'\n': '\\n',
'\f': '\\f',
'\r': '\\r',
'"' : '\\"',
'\\': '\\\\'
};
function quote(string) {
escapable.lastIndex = 0;
return escapable.test(string) ? '"' + string.replace(escapable, function (a) {
var c = meta[a];
return typeof c === 'string'
? c
: '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4);
}) + '"' : '"' + string + '"';
}
function str(key, holder, depthDecr, arrayMaxLength) {
var i, // The loop counter.
k, // The member key.
v, // The member value.
length,
partial,
value = holder[key];
if (value && typeof value === 'object' && typeof value.toPrunedJSON === 'function') {
value = value.toPrunedJSON(key);
}
switch (typeof value) {
case 'string':
return quote(value);
case 'number':
return isFinite(value) ? String(value) : 'null';
case 'boolean':
case 'null':
return String(value);
case 'object':
if (!value) {
return 'null';
}
if (depthDecr<=0 || seen.indexOf(value)!==-1) {
return '"-pruned-"';
}
seen.push(value);
partial = [];
if (Object.prototype.toString.apply(value) === '[object Array]') {
length = Math.min(value.length, arrayMaxLength);
for (i = 0; i < length; i += 1) {
partial[i] = str(i, value, depthDecr-1, arrayMaxLength) || 'null';
}
v = partial.length === 0
? '[]'
: '[' + partial.join(',') + ']';
return v;
}
for (k in value) {
if (Object.prototype.hasOwnProperty.call(value, k)) {
try {
v = str(k, value, depthDecr-1, arrayMaxLength);
if (v) partial.push(quote(k) + ':' + v);
} catch (e) {
// this try/catch due to some "Accessing selectionEnd on an input element that cannot have a selection." on Chrome
}
}
}
v = partial.length === 0
? '{}'
: '{' + partial.join(',') + '}';
return v;
}
}
JSON.pruned = function (value, depthDecr, arrayMaxLength) {
seen = [];
depthDecr = depthDecr || DEFAULT_MAX_DEPTH;
arrayMaxLength = arrayMaxLength || DEFAULT_ARRAY_MAX_LENGTH;
return str('', {'': value}, depthDecr, arrayMaxLength);
};
}());
An example of what can be done :
var json = JSON.pruned(window);
Note: Contrary to the code in this answer, the GitHub repository is updated when needed (documentation, compatibility, use as module in commonjs or node, specific serializations, etc.). It's a good idea to start from the repository if you need this pruning feature.
I've revised @dystroy's answer, adding:
- Indentation for sub-properties.
- An indication of where circular references point to.
/**
* Returns the JSON representation of an object.
*
* @param {value} object the object
* @param {number} objectMaxDepth for objects, the maximum number of times to recurse into descendants
* @param {number} arrayMaxLength for arrays, the maximum number of elements to enumerate
* @param {string} indent the string to use for indentation
* @return {string} the JSON representation
*/
var toJSON = function(object, objectMaxDepth, arrayMaxLength, indent)
{
"use strict";
/**
* Escapes control characters, quote characters, backslash characters and quotes the string.
*
* @param {string} string the string to quote
* @returns {String} the quoted string
*/
function quote(string)
{
escapable.lastIndex = 0;
var escaped;
if (escapable.test(string))
{
escaped = string.replace(escapable, function(a)
{
var replacement = replacements[a];
if (typeof (replacement) === "string")
return replacement;
// Pad the unicode representation with leading zeros, up to 4 characters.
return "\\u" + ("0000" + a.charCodeAt(0).toString(16)).slice(-4);
});
}
else
escaped = string;
return "\"" + escaped + "\"";
}
/**
* Returns the String representation of an object.
*
* Based on <a href="https://github.com/Canop/JSON.prune/blob/master/JSON.prune.js">https://github.com/Canop/JSON.prune/blob/master/JSON.prune.js</a>
*
* @param {string} path the fully-qualified path of value in the JSON object
* @param {type} value the value of the property
* @param {string} cumulativeIndent the indentation to apply at this level
* @param {number} depth the current recursion depth
* @return {String} the JSON representation of the object, or "null" for values that aren't valid
* in JSON (e.g. infinite numbers).
*/
function toString(path, value, cumulativeIndent, depth)
{
switch (typeof (value))
{
case "string":
return quote(value);
case "number":
{
// JSON numbers must be finite
if (isFinite(value))
return String(value);
return "null";
}
case "boolean":
return String(value);
case "object":
{
if (!value)
return "null";
var valueIndex = values.indexOf(value);
if (valueIndex !== -1)
return "Reference => " + paths[valueIndex];
values.push(value);
paths.push(path);
if (depth > objectMaxDepth)
return "...";
// Make an array to hold the partial results of stringifying this object value.
var partial = [];
// Is the value an array?
var i;
if (Object.prototype.toString.apply(value) === "[object Array]")
{
// The value is an array. Stringify every element
var length = Math.min(value.length, arrayMaxLength);
// Whether a property has one or multiple values, they should be treated as the same
// object depth. As such, we do not increment the object depth when recursing into an
// array.
for (i = 0; i < length; ++i)
{
partial[i] = toString(path + "." + i, value[i], cumulativeIndent + indent, depth,
arrayMaxLength);
}
if (i < value.length)
{
// arrayMaxLength reached
partial[i] = "...";
}
return "\n" + cumulativeIndent + "[" + partial.join(", ") + "\n" + cumulativeIndent +
"]";
}
// Otherwise, iterate through all of the keys in the object.
for (var subKey in value)
{
if (Object.prototype.hasOwnProperty.call(value, subKey))
{
var subValue;
try
{
subValue = toString(path + "." + subKey, value[subKey], cumulativeIndent + indent,
depth + 1);
partial.push(quote(subKey) + ": " + subValue);
}
catch (e)
{
// this try/catch due to forbidden accessors on some objects
if (e.message)
subKey = e.message;
else
subKey = "access denied";
}
}
}
var result = "\n" + cumulativeIndent + "{\n";
for (i = 0; i < partial.length; ++i)
result += cumulativeIndent + indent + partial[i] + ",\n";
if (partial.length > 0)
{
// Remove trailing comma
result = result.slice(0, result.length - 2) + "\n";
}
result += cumulativeIndent + "}";
return result;
}
default:
return "null";
}
}
if (indent === undefined)
indent = " ";
if (objectMaxDepth === undefined)
objectMaxDepth = 0;
if (arrayMaxLength === undefined)
arrayMaxLength = 50;
// Matches characters that must be escaped
var escapable =
/[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g;
// The replacement characters
var replacements =
{
"\b": "\\b",
"\t": "\\t",
"\n": "\\n",
"\f": "\\f",
"\r": "\\r",
"\"": "\\\"",
"\\": "\\\\"
};
// A list of all the objects that were seen (used to avoid recursion)
var values = [];
// The path of an object in the JSON object, with indexes corresponding to entries in the
// "values" variable.
var paths = [];
return toString("root", object, "", 0);
};
If you're using Node.js you can use util.inspect
, which takes a depth argument.