How I Nearly Lost My Yearly Bonus Because of JSON.stringify

This is a true story that happened to me. Misuse JSON.stringify broke the site. This seriously affected the user experience, I almost lost my annual bonus.

In this article, I will share the story with you and then we will also talk about the features JSON.stringify.

History

A colleague left, I took over part of his work. Suddenly, shortly after his departure, a bug appeared in the project.
The team began to discuss how to solve this problem:

  • Product Manager: users can no longer submit the form, this causes a lot of complaints.

  • QA: I tested this page! How could it break without new releases?

  • back end developer: frontend not sending data to field value. This results in an error.

Oops, this error seems to be happening in my module. I began to puzzle over a solution to the problem.

After a while, I found the reason.
There was a form on the page. After entering the data, the user clicked the submit button. The frontend collects data from the form and sends it to the backend.

The form looked something like this:

These fields are optional.

Under normal circumstances, the data should look like this:

let data = {  
	signInfo: [    
  	{      
    	"fieldId": 539,      
      "value": "silver card"    
    },    
    {      
    	"fieldId": 540,      
      "value": "2021-03-01"    
    },
    {      
      "fieldId": 546,
      "value": "10:30"
    }  
  ]
}

With help JSON.stringify this translates to:

But, as I said, these fields are optional. Therefore, if the user does not fill them in, the result may look like this:

let data = {
	signInfo: [
		{
      "fieldId": 539,
      "value": undefined
    },
    {
      "fieldId": 540,
      "value": undefined
    },
    {
      "fieldId": 546,
      "value": undefined
    }
  ]
}

Using JSON.stringify this translates to:

JSON.stringify ignores fields whose value is undefined during conversion. When this data is sent, the backend cannot process it. This results in an error.

The cause of the problem is found, and the solution is also very simple, convert the element whose value is not defined to an empty string.

let signInfo = [  
	{    
  	fieldId: 539,
    value: undefined
  },
  {    
  	fieldId: 540,
    value: undefined
  },
  {    
  	fieldId: 546,
    value: undefined
  },
]

let newSignInfo = signInfo.map((it) => {  
	const value = typeof it.value === 'undefined' ? '' : it.value  
  
  return { ...it, value }
})

console.log(JSON.stringify(newSignInfo)) 

// '[{"fieldId":539,"value":""},{"fieldId":540,"value":""},{"fieldId":546,"value":""}]'

If the field value is undefinedthen we assign an empty string to it.

Let’s now think about how this problem came about. The page worked, everything was in order with it. Why did this problem suddenly appear, and before it was not? The fact is that the product manager wanted to add a little optimization. I felt the case was relatively small, so I changed the code without a thorough check. It was a big problem.

Luckily, we quickly found and fixed the problem.

Understanding JSON.stringify

The problem arose due to the fact that I did not fully understand how it works JSON.stringifyso let’s take a look at the capabilities of this built-in function.

In fact, JSON.stringify converts an object to a JSON string:

In the same time JSON.stringify has the following rules:

  1. If the value has toJSON()method, it is responsible for determining how the data will be serialized.

  2. Boolean, NumberAnd Stringobjects are converted to their respective primitive values.

  3. undefined, FunctionAnd Symbolinvalid JSON values. If any such values ​​are encountered during conversion, they are either discarded (if they are in the object) or replaced with null(if they are in an array).

  4. Everything Symbolproperties with a key will be completely ignored

  5. Dateimplements toJSON()function, returning a string (same as date.toISOString()). So the result will be the string:

  6. Values InfinityAnd NaN converted to null

  7. All other objects (including Map, Set, WeakMap And WeakSet) will only have enumerable properties:

  8. If the object contains a circular reference, then an error will be raised. TypeError ("cyclic object value"):

  9. An error is thrown when trying to convert a value of type BigInt:

Implementing JSON.stringify on our own

The best way to understand a function is to implement it yourself. Below I have written a simple function that mimics JSON.stringify.

const jsonstringify = (data) => {
  // Check if an object has a circular reference
  const isCyclic = (obj) => {
    // Use a Set to store the detected objects
    let stackSet = new Set()
    let detected = false

    const detect = (obj) => {
      // If it is not an object, we can skip it directly
      if (obj && typeof obj != 'object') {
        return
      }
      // When the object to be checked already exists in the stackSet, 
      // it means that there is a circular reference
      if (stackSet.has(obj)) {
        return detected = true
      }
      // save current obj to stackSet
      stackSet.add(obj)

      for (let key in obj) {
        // check all property of `obj`
        if (obj.hasOwnProperty(key)) {
          detect(obj[key])
        }
      }
      // After the detection of the same level is completed, 
      // the current object should be deleted to prevent misjudgment
      /*
        For example: different properties of an object may point to the same reference,
        which will be considered a circular reference if not deleted
        
        let tempObj = {
          name: 'bytefish'
        }
        let obj4 = {
          obj1: tempObj,
          obj2: tempObj
        }
      */
      stackSet.delete(obj)
    }

    detect(obj)

    return detected
  }

  // Throws a TypeError ("cyclic object value") exception when a circular reference is found.
  if (isCyclic(data)) {
    throw new TypeError('Converting circular structure to JSON')
  }

  // Throws a TypeError  when trying to stringify a BigInt value.
  if (typeof data === 'bigint') {
    throw new TypeError('Do not know how to serialize a BigInt')
  }

  const type = typeof data
  const commonKeys1 = ['undefined', 'function', 'symbol']
  const getType = (s) => {
    return Object.prototype.toString.call(s).replace(/\[object (.*?)\]/, '$1').toLowerCase()
  }

  if (type !== 'object' || data === null) {
    let result = data
    // The numbers Infinity and NaN, as well as the value null, are all considered null.
    if ([NaN, Infinity, null].includes(data)) {
      result="null"
     
      // undefined, arbitrary functions, and symbol values are converted individually and return undefined
    } else if (commonKeys1.includes(type)) {
      
      return undefined
    } else if (type === 'string') {
      result=""" + data + '"'
    }

    return String(result)
  } else if (type === 'object') {
    // If the target object has a toJSON() method, it's responsible to define what data will be serialized.

    // The instances of Date implement the toJSON() function by returning a string (the same as date.toISOString()). Thus, they are treated as strings.
    if (typeof data.toJSON === 'function') {
      return jsonstringify(data.toJSON())
    } else if (Array.isArray(data)) {
      let result = data.map((it) => {
        // 3# undefined, Functions, and Symbols are not valid JSON values. If any such values are encountered during conversion they are either omitted (when found in an object) or changed to null (when found in an array).
        return commonKeys1.includes(typeof it) ? 'null' : jsonstringify(it)
      })

      return `[${result}]`.replace(/'/g, '"')
    } else {
      // 2# Boolean, Number, and String objects are converted to the corresponding primitive values during stringification, in accord with the traditional conversion semantics.
      if (['boolean', 'number'].includes(getType(data))) {
        return String(data)
      } else if (getType(data) === 'string') {
        return '"' + data + '"'
      } else {
        let result = []
        // 7# All the other Object instances (including Map, Set, WeakMap, and WeakSet) will have only their enumerable properties serialized.
        Object.keys(data).forEach((key) => {
          // 4# All Symbol-keyed properties will be completely ignored
          if (typeof key !== 'symbol') {
            const value = data[key]
            // 3# undefined, Functions, and Symbols are not valid JSON values. If any such values are encountered during conversion they are either omitted (when found in an object) or changed to null (when found in an array).
            if (!commonKeys1.includes(typeof value)) {
              result.push(`"${key}":${jsonstringify(value)}`)
            }
          }
        })

        return `{${result}}`.replace(/'/, '"')
      }
    }
  }
}

Conclusion

Due to a bug, I had to figure out the features of the work JSON.stringifyand even write your own implementation of this function.

I hope this article will help you avoid my mistake in the future.

Similar Posts

Leave a Reply