How to safely work with nested objects in JavaScript

Why do we need to be careful when working with nested objects?

If you have worked with APIs before, you have most likely work with deeply nested objects.
Consider the following object

const someObject = {
    "type" : "Objects",
    "data": [
        {
            "id" : "1",
            "name" : "Object 1",
            "type" : "Object",
            "attributes" : {
                "color" : "red",
                "size" : "big",
                "arr": [1,2,3,4,5]
            },
        },
        {
            "id" : "2",
            "name" : "Object 2",
            "type" : "Object",
            "attributes" : {}
        },
    ]
}

Let's try accessing some values

console.log(
    someObject.data[0].attributes.color
)
// red

This works fine but what if we try to access the 'color' property of the second element in data.

console.log(
    someObject.data[1].attributes.color
)
// undefined

It prints undefined because the property 'aatributes' is empty. Let's try accessing second element inside the property 'arr'.

console.log(
    someObject.data[0].attributes.arr[1]
)
// 2


console.log(
    someObject.data[1].attributes.arr[1]
)
// TypeError: Cannot read property '1' of 
// undefined

In the first case, 2 is printed in the console. However in the second case we get an error.

This is because 'someObject.data[1].attributes' is empty and therefore 'attributes.arr' is undefined. When we try accessing 'arr[1]', we are actually trying to index undefined which causes an error.

We could put the code inside a try..catch block to handle the error gracefully but if you have quite a few cases where you need to access deeply nested values, your code will look verbose.

Let's look at another scenario. This time we want to update the value of the element at index 0 in 'arr'

someObject.data[0].attributes.arr[0] = 200;
console.log(someObject.data[0].attributes.arr);
// [ 200, 2, 3, 4, 5 ]

someObject.data[1].attributes.arr[0] = 300;
// TypeError: Cannot set property '0' of 
// undefined

We get a similar Type Error again.

Safely accessing deeply nested values

Using Vanilla JS

We can use the Optional chaining (?.) operator

console.log(
    someObject?.data[1]?.attributes?.color
)
// undefined

console.log(
    someObject?.data?.[1]?.attributes?.arr?.[0]
)
// undefined

Notice this time it doesn't cause an error, instead it prints undefined. The ?. causes the expression to short-circuit, i.e if the data to the left of ?. is undefined or null, it returns undefined and doesn't evaluate the expression further.

Using Lodash

If you do not want to see a bunch of question marks in your code, you can use Lodash's get function. Below is the syntax

get(object, path, [defaultValue])

First, we will need to install lodash

npm install lodash

Below is code snippet which uses the get function

const _ = require('lodash');

console.log(
    _.get(someObject,
   'data[1].attributes.color', 
   'not found')
)
// not found

console.log(
    _.get(someObject,
    'data[1].attributes.arr[0]')
)
// undefined

The default value is optional, if you do not specify the default value, it will simply return undefined.

Using Rambda

We can either use the 'path' function or the 'pathOr' function. The difference is that with the 'pathOr' function, we can specify a default value.

To install Rambda

npm install rambda

Below is the code snippet to access the values

console.log(
  R.pathOr(
      ["data", 1, "attributes", "color"], 
      someObject, 
      "not found")
);
// not found

console.log(
    R.path(
        ["data", 1, "attributes", "arr", 0], 
        someObject
        )
);
// undefined

Safely setting values for deeply nested objects

Using Lodash

We can use Lodash's set function. Below is the synax

set(object, path, value)

If we provide a path that doesn't exist, it will create the path.

const _ = require("lodash");

_.set(
    someObject
    ,"data[1].attributes.arr[1]"
    , 200
);

console.log(
    _.get(
        someObject,
        'data[1]'
    )
)

/*
{
  id: '2',
  name: 'Object 2',
  type: 'Object',
  attributes: { 
      arr: [ 
          <1 empty item>, 
          200 
        ] 
}
}
*/

Initially the property 'attributes' was empty but when tried setting a value for 'attributes.arr[1]', a property 'arr' was added to 'attributes' and then an empty element was added and then 200 was added.

Basically if the path we specify doesn't exist, it will create that path and set the value.

Using Rambda

We can do something similar to Lodash's set function using assocPath function in Rambda.

const R = require("ramda");

const newObj = 
     R.assocPath(
        ['data','1','attributes','arr',1]
        ,200
        ,someObject
    )
console.log(
    R.path(['data','1'],newObj)
)

/*
{
  id: '2',
  name: 'Object 2',
  type: 'Object',
  attributes: { 
      arr: [ 
          <1 empty item>, 
          200 
        ] 
}
}
*/

assocPath is not an in-place function, i.e it does not update the object. It returns a new object.

24