Set By Path

Setting a nested property by path with Javascript

January 25, 2022 • 7 min read

Following RamdaJS's assocPath implementation as a guide we are going to write a function that given a path, a value, and a source object will return a new object with that property set.

const sourceObject = {
    a: {
        b: {
            c: 10;
        }
    }
}

const copiedObject = setByPath([a, b, c], 20, sourceObject)

sourceObject.a.b.c // -> 10
copiedObject.a.b.c // -> 20

To kick things off I want to try and keep it simple, essentially following the route I took to creating the final function. We will create an initial function that taking a path and a value will create a new nested object with a value set to the tailend property.

buildByPath(['a', 'b', 'c'], 'Set') //-> { a: { b: { c: 'Set' } } }

We will use recursion to do this. On each recursive call using destructuring and the spread syntax we split our path into a single key and remaining keys — this is repeated until we run out of remaining keys. Destructuring enables us to make our code a bit more readible e.g. restOfKeys.length is a little easier to understand than path.slice(1).length.

const buildByPath = function buildByPath([key, ...restOfKeys], value) {

    const obj = {
      [key]: (restOfKeys.length)
          // whilst there is more than one key in the path 
          // recursively call buildByPath
          ? buildByPath(restOfKeys, value)
          // otherwise start to return objects, starting with 
          // the tailend { [key]: value }
          : value 
    }
    return obj
}

When we reach the last key in our path and the base condition check, an object is created with that key property set to the passed value. This is then returned becoming the value for the previous key and so on.

Arrays and Objects

Another characteristic of assocPath is that the path can contain letters and numbers. If the value is a letter we have a key in an object otherwise if the value is a number we have an index in an array.

buildByPath(['a', 'b', 0], 48) // -> {a : {b: [48] } }

To implement this we will need to take things one step further by adding a source object parameter to our function. We also need to examine the next key in our path to see if it is a number or a letter. A simple way to do this is to use isNaN to check if our value is not a number. We can then pass the correct object type as an argument to source object.

// this time buildByPath expects a source argument
const buildByPath = 
    function buildByPath([key, nextKey, ...restOfKeys], value, source) {

    // again are we down to last key?
    source[key] = (nextKey !== undefined)
        ? buildByPath(
            [nextKey, ...restOfKeys], 
            value,
            // a simple check to see if the next key is 
            // not a number e.g object otherwise an array
            isNaN(nextKey) ? {} : []
          )
        : value

    return source
}

buildObjectsTree(['a', 'b', 0], 'index 0', {}) // -> { a: { b: ['index 0'] } }

Merging Properties with Source

So far we have only been working with empty objects. We need to be able to set a property on an existing object.

You can see if we supply a source object with existing properties the current buildByPath function fails, completely overwriting those properties.

buildByPath(['a', 'b', 'c'], 48, { a: { d: 50 } }) // -> { a: { b: { c: 48 } } }

We can fix this by checking to see if the current key already exists in our source object using hasOwnProperty; If it does then we pass that key's property to our recursive call, otherwise as with the previous function we pass a new object.

// For brevity we will create a hasOwn helper function
const hasOwn = (obj, key) => 
    Object.prototype.hasOwnProperty.call(obj, key)

const buildByPathMerge = 
    function buildByPath([key, nextKey, ...restOfKeys], value, source) {

    source[key] = (nextKey !== undefined)
        ? buildByPath(
          [nextKey, ...restOfKeys], 
          value,
          // Key already exists?
          (hasOwn(source, key))
            ? source[key] // if so pass exisiting property instead
            : isNaN(nextKey) ? {} : []
        )
        : value

    return source
}

Repeating the previous call now returns new properties merged with the source object as intended.

buildByPathMerge(['a', 'b', 'c'], 48, { a: { d: 50 } }) 
// { a: { d: 50, 'b': { c: 48 } } }

Avoiding mutation

As with all good functional programming we need to avoid changing or mutating our source data. In our current implementation again we fail, mutating the source object.

const sourceObj = { a: 52 }
buildByPathMerge(['a', 'b', 'c'], 48, sourceObj)
sourceObj // -> { a: { b: { c: 48 } } } No good!!

Cloning (updated)

In my initial solution, inline with ramdaJS's assocPath I ran with returning shallow copies using Object.assign in the form of a helper function.

const assignObjects = (key, value, source) => (
    Object.assign(
        source.constructor(), source, { [key]: value }
    )
)

This has a drawback, especially when the tailend of the path contains nested objects. Let's use an example and ramdaJs's assocPath to illustrate. We will start with creating a user object.

const user = {
    id: 1,
    personalInfo: {
        name: 'Robert',
        address: {
            road: 'Quartier Djinageryber',
            city: 'Timbuktu',
            country: 'Mali'
        }
    }
}

We then set a new name, and modify the address on the updated copy.

const updatedUser = R.assocPath(['personalInfo', 'name'], 'Bobby', user)

// Amend to address
const address = updatedUser.personalInfo.address
address.road = '12 Kangzhu Blvd'
address.city = 'Shangri-La City'
address.country = 'China'

If outputs are logged for both the source and the updated copy, it is clear that there is an issue with shared references.

// user personalInfo
{ 
    name: 'Robert',
    address: {
        // address properties should not have changed.
        road: '12 Kangzhu Blvd', 
        city: 'Shangri-La City', 
        country: 'China' 
    }
}

// updateUser personalInfo
{ 
    name: 'Bobby',
    address: { 
        road: '12 Kangzhu Blvd', 
        city: 'Shangri-La City', 
        country: 'China' 
    }
}

The name property changes have worked perfectly well, but the address properties has been modifed on both the source and copy.

The reason for this is quite simple. The personalInfo property was our tailend property and contained a nested address object. As we only shallow copied personalInfo, setting a new name property, the nested address object within was only copied across by reference.

If this is good enough for ramdaJS then who am I to question it, but if we do want to be a bit more thorough then we need to look at making a deep clone instead. You can find an implementation of deep clone in my previous article deepClone.

Final setByPath

So here is the updated implementation. We wrap our function in an outer function, so that we can pass a clone of the source object to the inner function and return a mutated result.

const setByPath = function (path, value, source) {

    function setByPath ([key, nextKey, ...restOfKeys], value, source) {

        source[key] = (nextKey !== undefined)
            ? setByPath(
                [nextKey, ...restOfKeys],
                value,
                (hasOwn(source, key))
                    ? source[key]
                    : isNaN(nextKey) ? {} : []
            )
            : value

        return source
    }
    // pass in a deep clone of the source object
    return setByPath(path, value, deepClone(source))
}

If we repeat the above exercise with our final setByPath we can see we have dealt with the problem of shared references.

const updatedUser = setByPath(['personalInfo', 'name'], 'Bob', user)

// As before
const address = updatedUser.personalInfo.address
address.road = '12 Kangzhu Blvd' ...

// user personalInfo with unchanged address
{ 
    name: 'Robert',
    address: { 
        road: 'Quartier Djinageryber',
        city: 'Timbuktu',
        country: 'Mali' 
    }
}

// updatedUser personalInfo and modified address
{ 
    name: 'Bob',
    address: { 
        road: '12 Kangzhu Blvd', 
        city: 'Shangri-La', 
        country: 'China' 
    }
}