DEV Community

John Au-Yeung
John Au-Yeung

Posted on • Updated on

Some Things About JSON That You May Have Missed

Subscribe to my email list now at http://jauyeung.net/subscribe/

Follow me on Twitter at https://twitter.com/AuMayeung

Many more articles at https://medium.com/@hohanga

JSON stands for JavaScript Object Notation. It is a format for serializing data, which means that it can be used to transmit and receive data between different sources. In JavaScript, there’s a JSON utility object that provides methods to convert JavaScript objects to JSON strings and vice versa. The JSON utility object can’t be constructed or called — there are only 2 static methods which are stringify and parse to convert between JavaScript objects and JSON strings.

Properties of JSON

JSON is a syntax for serializing objects, arrays, numbers, booleans, and null. It is based on the JavaScript object syntax, but they are not the same thing. Not all JavaScript object properties can be converted to valid JSON, and JSON strings must be correctly formatted to be converted into a JavaScript object.

For objects and arrays, JSON property names must be in double-quoted strings, and trailing commas for objects are prohibited. Numbers cannot have leading zeroes, and a decimal point must be followed by at least one digit. NaN and Infinity aren’t supported, and JSON strings can’t have undefined or comments. In addition, JSON can not contain functions.

Any JSON text must contain valid JavaScript expressions. In some browser engines, the U+2028 line separator and U+2029 paragraph separator are allowed in string literals and property keys in JSON, but when using them in JavaScript code will result in SyntaxError. Those 2 characters can be parsed with JSON.parse into valid JavaScript strings, but fails when passed into eval.

Insignificant whitespace may be included anywhere except within JSONNumber or JSONString. Numbers can’t have whitespace inside and strings would be interpreted as whitespace in the string or cause an error. The tab character (U+0009), carriage return (U+000D), line feed (U+000A), and space (U+0020) characters are the only valid whitespace characters in JSON.

Basic Usage of the JSON Object

There are 2 methods on the JSON utility object. There is the stringify method for converting a JavaScript object to a JSON string and the parse method for converting a JSON string to a JavaScript object.

The parse method parses a string as JSON with a function as a second argument to optionally transform JSON entities to the JavaScript entity that you specified and return the resulting JavaScript object. If the string has entities that aren’t allowed in the JSON syntax, then a SyntaxError would be raised. Also, tailing commas aren’t allowed in the JSON string that is passed into JSON.parse. For example, we can use it as in the following code:

JSON.parse('{}'); // {}       
JSON.parse('false'); // false        
JSON.parse('"abc"'); // 'abc'         
JSON.parse('[1, 5, "abc"]');  // [1, 5, 'abc']  
JSON.parse('null'); // null

The first line would return an empty object. The second would return false. The third line would return 'abc'. The fourth line would return [1, 5, "abc"]. The fifth line would return null. It returns what we expect since every line we pass in is valid JSON.

Customize the Behavior of Stringify and Parse

Optionally, we can pass in a function as the second argument to convert values to whatever we want. The function we pass in will take the key as the first parameter and the value as the second and returns the value after manipulation is done. For example, we can write:

JSON.parse('{"a:": 1}', (key, value) =>  
  typeof value === 'number'  
    ? value * 10  
    : value       
);

Then we get {a: 10} returned. The function returns the original value multiplied by 10 if the value’s type is a number.

The JSON.stringify method can take a function as the second parameter that maps entities in the JavaScript object to something else in JSON. By default, all instances of undefined and unsupported native data like functions are removed. For example, if we write the following code:

const obj = {  
  fn1() {},  
  foo: 1,  
  bar: 2,  
  abc: 'abc'  
}  
const jsonString = JSON.stringify(obj);  
console.log(jsonString);

Then we see that fn1 is removed from the JSON string after running JSON.stringify since functions aren’t supported in JSON syntax. For undefined, we can see from the following code that undefined properties will be removed.

const obj = {  
  fn1() {},  
  foo: 1,  
  bar: 2,  
  abc: 'abc',  
  nullProp: null,  
  undefinedProp: undefined  
}  
const jsonString = JSON.stringify(obj);  
console.log(jsonString);

undefinedProp is not in the JSON string logged because it has been removed by JSON.strinfiy.

Also, NaN and Infinity all become null after converting to a JSON string:

const obj = {  
  fn1() {},  
  foo: 1,  
  bar: 2,  
  abc: 'abc',  
  nullProp: null,  
  undefinedProp: undefined,  
  notNum: NaN,  
  infinity: Infinity  
}  
const jsonString = JSON.stringify(obj);  
console.log(jsonString);

We see that:

'{“foo”:1,”bar”:2,”abc”:”abc”,”nullProp”:null,”notNum”:null,”infinity”:null}'

NaN and Infinity have both become null instead of the original values.

For unsupported values, we can map them to supported values with the replacer function in the second argument which we can optionally pass in. The replace function takes the key of a property as the first parameter and the value as the second parameter. For example, one way to keep NaN, Infinity, or functions is to map them to a string like in the following code:

const obj = {  
  fn1() {},  
  foo: 1,  
  bar: 2,  
  abc: 'abc',  
  nullProp: null,  
  undefinedProp: undefined,  
  notNum: NaN,  
  infinity: Infinity  
}

const replacer = (key, value) => {  
  if (value instanceof Function) {  
    return value.toString();  
  } 
  else if (value === NaN) {  
    return 'NaN';  
  } 
  else if (value === Infinity) {  
    return 'Infinity';  
  } 
  else if (typeof value === 'undefined') {  
    return 'undefined';  
  } 
  else {  
    return value; // no change  
  }  
}

const jsonString = JSON.stringify(obj, replacer, 2);  
console.log(jsonString);

After running console.log on jsonString in the last line, we see that we have:

{  
  "fn1": "fn1() {}",  
  "foo": 1,  
  "bar": 2,  
  "abc": "abc",  
  "nullProp": null,  
  "undefinedProp": "undefined",  
  "notNum": null,  
  "infinity": "Infinity"  
}

What the replace function did was add additional parsing using the key and the value from the object being converted with JSON.stringify. It checks that if the value is a function, then we convert it to a string and return it. Likewise, with NaN, Infinity, and undefined, we did the same thing. Otherwise, we return the value as-is.

The third parameter of the JSON.stringfy function takes in a number to set the number of whitespaces to be inserted into the output of the JSON to make the output more readable. The third parameter can also take any string that will be inserted instead of whitespaces. Note that if we put a string as the third parameter that contains something other than white space(s), we may create a “JSON” a string that is not valid JSON.

For example, if we write:

const obj = {  
  fn1() {},  
  foo: 1,  
  bar: 2,  
  abc: 'abc',  
  nullProp: null,  
  undefinedProp: undefined,  
  notNum: NaN,  
  infinity: Infinity  
}
const replacer = (key, value) => {  
  if (value instanceof Function) {  
    return value.toString();  
  } 
  else if (value === NaN) {  
    return 'NaN';  
  } 
  else if (value === Infinity) {  
    return 'Infinity';  
  } 
  else if (typeof value === 'undefined') {  
    return 'undefined';  
  } 
  else {  
    return value; // no change  
  }  
}
const jsonString = JSON.stringify(obj, replacer, 'abc');  
console.log(jsonString);

Then console.log will be:

{  
abc"fn1": "fn1() {}",  
abc"foo": 1,  
abc"bar": 2,  
abc"abc": "abc",  
abc"nullProp": null,  
abc"undefinedProp": "undefined",  
abc"notNum": null,  
abc"infinity": "Infinity"  
}

Which is obviously not valid JSON. JSON.stringify will throw a “cyclic object value” TypeError. Also, if an object has BigInt values, then the conversion will fail with a “BigInt value can’t be serialized in JSON” TypeError.

Also, note that Symbols are automatically discarded with JSON.stringify if they are used as a key in an object. So if we have:

const obj = {  
  fn1() {},  
  foo: 1,  
  bar: 2,  
  abc: 'abc',  
  nullProp: null,  
  undefinedProp: undefined,  
  notNum: NaN,  
  infinity: Infinity,  
  [Symbol('foo')]: 'foo'  
}

const replacer = (key, value) => {
  if (value instanceof Function) {  
    return value.toString();  
  } 
  else if (value === NaN) {  
    return 'NaN';  
  } 
  else if (value === Infinity) {  
    return 'Infinity';  
  } 
  else if (typeof value === 'undefined') {  
    return 'undefined';  
  } 
  else {  
    return value; // no change  
  }  
}

const jsonString = JSON.stringify(obj, replacer, 2);  
console.log(jsonString);

We get back:

{  
  "fn1": "fn1() {}",  
  "foo": 1,  
  "bar": 2,  
  "abc": "abc",  
  "nullProp": null,  
  "undefinedProp": "undefined",  
  "notNum": null,  
  "infinity": "Infinity"  
}

Date objects are converted to strings by using the same string as what date.toISOString() will return. For example, if we put:

const obj = {  
  fn1() {},  
  foo: 1,  
  bar: 2,  
  abc: 'abc',  
  nullProp: null,  
  undefinedProp: undefined,  
  notNum: NaN,  
  infinity: Infinity,  
  [Symbol('foo')]: 'foo',  
  date: new Date(2019, 1, 1)  
}  
const replacer = (key, value) => {  
  if (value instanceof Function) {  
    return value.toString();  
  } 
  else if (value === NaN) {  
    return 'NaN';  
  } 
  else if (value === Infinity) {  
    return 'Infinity';  
  } 
  else if (typeof value === 'undefined') {  
    return 'undefined';  
  } 
  else {  
    return value; // no change  
  }  
}  
const jsonString = JSON.stringify(obj, replacer, 2);  
console.log(jsonString);

We get:

{  
  "fn1": "fn1() {}",  
  "foo": 1,  
  "bar": 2,  
  "abc": "abc",  
  "nullProp": null,  
  "undefinedProp": "undefined",  
  "notNum": null,  
  "infinity": "Infinity",  
  "date": "2019-02-01T08:00:00.000Z"  
}

As we can see, the value of the date property is now a string after converting to JSON.

Deep Copy Objects

We can also use JSON.stringify with JSON.parse to make a deep copy of JavaScript objects. For example, to do a deep copy of a object without a library, you can JSON.stringify then JSON.parse:

const a = { foo: {bar: 1, {baz: 2}}  
const b = JSON.parse(JSON.stringfy(a)) // get a clone of a which you can change with out modifying a itself

This does a deep copy of an object, which means all levels of an object are cloned instead of referencing the original object. This works because JSON.stringfy converted the object to a string which are immutable, and a copy of it is returned when JSON.parse parses the string which returns a new object that doesn’t reference the original object.

Discussion (12)

Collapse
patarapolw profile image
Pacharapol Withayasakpunt • Edited on

Another hidden ability of JavaScript JSON is Date.prototype.toJSON, which will customize JSON.stringify behavior of the object.

Collapse
aumayeung profile image
John Au-Yeung Author

Thanks for finding that.

That's a method that most people don't think of using.

Collapse
stephenmirving profile image
Stephen Irving • Edited on

Just so you know, including multiple return paths for functions is an anti-pattern/code-smell. It is a best practice for a function to only have a single return path if that is possible. So, for example, in your third to last snippet, you could have written the replacer function like this instead and have what it returns be the same (I also fixed your NaN check to use Number.isNaN() because using equality to check against NaN will not work as expected):

const replacer = (key, value) => (
  typeof value === 'undefined'
    ? 'undefined'
    : (
      value instanceof Function || Number.isNaN(value) || value === Infinity
        ? value.toString()
        : value
    )
);
Collapse
petergabriel2 profile image
PeterGabriel2 • Edited on

Absolutely wrong! In every case is switch more readable than joined or/and superlongline. More easy to update. And you will not make error in it so easily like in bunch of writeonly code you just wrote.

Collapse
aumayeung profile image
John Au-Yeung Author

I think ternary operator shouldn't be nested. It's harder to read than if or switch like you said.

Collapse
nicojs profile image
Nico Jansen

value === NaN is always false. For example NaN === NaN. Use isNaN().

Collapse
aumayeung profile image
John Au-Yeung Author

Thanks. That's a good catch.

Collapse
stephenmirving profile image
Stephen Irving

You should actually be using Number.isNan() over the global isNaN function which will give somewhat unexpected results in comparison to the newer method on the Number object. You should also use !Number.isFinite() instead of checking equality with Infinity.

Thread Thread
aumayeung profile image
John Au-Yeung Author

Thanks. That's also a good tip.

I think equality is still good as long as we use triple equals.

Thread Thread
stephenmirving profile image
Stephen Irving

Yes, you can still write it that way.

Collapse
sams4s profile image
sam-s4s

Also if the object or class that you're converting has a toJSON function, it will use that to determine the output - can be very handy :)

developer.mozilla.org/en-US/docs/W...

Collapse
aumayeung profile image
John Au-Yeung Author

Yea. Then we don't have to passing a mapping function every time we want to stringify. I think URL and URLSearchParams objects have this method.