Setting nested object

Recently i had to accomplish a pretty common task which quickly become my nightmare!
I then decided to share my solution and approach here in case someone else out there is going nuts like i was doing.

The task is: Set new value to an existing json object, knowing the path where the new value should be set.

So the elements we have here are:

  • 1 json object
  • 1 array of objects where each object has two property: 1) An array of string that together compose the path to the new value and 2) the new value.

Here they are:

var obj = { "users": {  
              "profile": {
                 "name": "markus",
                 "age": 28 
              }
          }
}
// changes array
var changes = [  
    {
      path: ['users', 'profile', 'name'],
      value: "Nino"
  },
  {
      path: ['users', 'profile', 'eye'],
      value: "blue"
  },

 ];

We are going to write a function, let's say set, that it will set the new value to the json obj, then with another function we will go through all changes objects and give a message if successfully updated.

Our set function should be used more or less like this:
set(originalObj, pathToValue, newValue);

First Approach

First approach is to go through the changes array, then loop through the change's path and finally call path within json obj and then assign it to the new value.

changes.forEach( function(changeObj){  
    var path = changeObj.path;
    set(obj, path, changeObj.value);
  });

function set(obj, path, value){  
  // check if value or path are empty
  if(path.length === 0 ||   value.length === 0) {
    obj = {};
    return obj;
  } else {
     obj[path] = value; // can't work!
  }
}

First issue: obj[path] doesn't make sense.
In this moment path is equal to ['users', 'profile', 'name'] so set an array as path to an obj it just doesn't work! Argh!
Then how can we take each array element and add it separately to the obj so it can be read as path?

Main problem

function set(obj, path, value){  
  // check if value or path are empty
  if(path.length === 0 ||   value.length === 0) {
    obj = {};
    return obj;
  } else {
     obj[path[0]] = value; // It works!
  }
}

ok, that's how it should look like but it clearly work just for the first element in the array!!
How can we make it work even if our path is deeper then 1 level?

Look odd but find the solution to this question take me really long time!
I search here and there in the big broad web, of course i found many people with my same issue but weird enough, not so many answers to this question!
Most of the proposed solutions suggest to use an utility library like lodash or underscore which come with a very handy .set or _set function.
Awesome, but i didn't want to use a library just for that!
I needed to find another solution.
I found some blog post about this topic but they were pretty advanced and cover lot more then what i needed, the only conversation i found helpful was
this Stackoverflow answer.
(yes is the first result you get if you give the correct search result but you know, i always look for second opinion in the big big web. Just to be sure ;) )

One way to do this...

function set(obj, path, value){  
  // check if value or path are empty
  if(path.length === 0 ||   value.length === 0) {
    obj = {};
    return obj;
  } else if(path.length === 1) {
     obj[path[0]] = value;
     return obj; 
  } else if(path.length === 2) {
     obj[path[0] ][ path[1] ]] = value;
     return obj; 
  } else if(path.length === 3) {
     obj[path[0] ][ path[1] ][ path[2]] = value;
     return obj; 
  } else if(path.length === 4) {
     obj[path[0] ][ path[1] ][ path[2] [ path[3]] = value;
     return obj; 
  } else {
     obj = {};
     return obj; 
  }
}

Here we succesfully change our value to the new one and we take care of all possible fail cases.
We basically check the deepness of our path and assign a correct deepness level to the obj in order to change the value.
This is work for sure and is also pretty clear but is not the most elegant solution.
Also is not very flexible since if we have level deeper then 4 we will need to go back and update our set function with a new else if block.
Not very handy.

Second Approach

The first solution isn't ideal but it helped us to understand exactly what's the approach we need to take in order to find better solutions.
We now know that our function should have three different step:

  • check if any value is empty, if they are, return
  • check if the path is 1 level, if it is, update the value
  • if is not 1 level deeper, deal with more levels.

With this three steps in mind, we can now just concentrare on last step and figured out how to "deal with more levels".
One way to do so is go through each item of the path array and each time we loop we should update the main obj with nested one, until we reach the last item and we can easily set the value.
The idea is to make the path array smaller until reach one item or one level object, so it can be easily assign to a new value.

Let's see the code:

function set(obj, path, value) {  
  if(path.length === 0 || value.length === 0) {
    obj = {};
    return obj;
  }

  if(path.length === 1){
    obj[path] = value;

  } else {
    for(var i = 0; i < path.length-1; i++) {
      var elem = path[i]; // ["users", "profile", "name"]

      obj = obj[elem]; // Here is where all the magic happen. obj become the nested one, not users anymore.
    }
    obj[path[path.length-1]] = value; // profile["name"] = "Nino"
  }

  return obj;
}

What is happening when the code find a 3 level path?
paths = ['users', 'profile', 'name']
It will take each el of the array and assign as root object:
i.e. now obj = users.profile{...}
Not to obj{...} anymore.

Once we reach the end of the for loop, is easy to update it:
profile['name']; //Markus and we change with profile['name'] = 'Nino'. And then we return obj.

This is working solution and much better then previous one! Yey!
Let's keep on working on it.
So far, with this function we can set a new value, but what's happen if the path where we want to change our value, doesn't exist?
In this case, would be great if we can simply add new value to a new path.
Let's add the code for it:

function set(obj, path, value) {  
  if(path.length === 0 || value.length === 0) {
    obj = {};
    return obj;
  }

  if(path.length === 1){
    obj[path] = value;

  } else {
    for(var i = 0; i < path.length-1; i++) {
      var elem = path[i]; // ["users", "profile", "name"]

      if( !obj[elem] ) { // if obj[profile] don't exist
        obj[elem] = {} // add path: obj = { "profile": { "eye": {} }};
      }
      obj = obj[elem];
    }
    obj[path[path.length-1]] = value; // value is assign to new path
// profile['eye'] = 'blue'
  }

  return obj;
}

Nice! Now we can set and add new value with our function.

Look like is time to put all together.
Another thing is bother me: we are working on the original json object.
What happen if we need to check back the original one? We don't have it anymore because we change it. Not the best solution probably.
Let's create a copy of our object with whom we can play around without begin afraid of change the original one.

var updatedObj = Object.assign(obj);

changes.forEach( function(changeObj){  
    var path = changeObj.path;
    set(obj, path, changeObj.value, updatedObj);
  });

And in our set function we will do:

function set(obj, path, value, updatedObj) {  
  if(path.length === 0 || value.length === 0) {
    updatedObj = {};
    return updatedObj;
  }

  if(path.length === 1){
    updatedObj[path] = value;

  } else {
    for(var i = 0; i < path.length-1; i++) {
      var elem = path[i]; 

      if( !updatedObj[elem] ) { 
        updatedObj[elem] = {}
      }
      updatedObj = updatedObj[elem];
    }
    updatedObj[path[path.length-1]] = value;
  }
  return updatedObj;
}

Good!
Now we just need to handle errors so we can give back message to the user in case the function has successfully save new value or not.
We already set updatedObj = {}; where the functon fail in setting new value.
So now we just need to check if any path return empty object and if it does, we should return false value.
We can easily handle that aissigning the false/true value to a variable.

var updatedObj = Object.assign(obj);  
var success = true;

changes.forEach( function(changeObj){  
    var path = changeObj.path;
    var result = set(obj, path, changeObj.value, updatedObj);
    if(Object.keys(result).length === 0 && result.constructor === Object){
      success = false;
    }
  });

Now we can send a message back to the user based on success variable.

if(!success){  
    alert("File not updated. One or more property are incorrect.")
  } else {
    alert("File was succefully updated")
  }

This is the way i ended up using.
It is working properly and it look quite clean but i would be more then happy to learn about different ways or techniques to achive same result so if bump into the same problem and have a nice solution, feel free to share in the comments belove!