7
\$\begingroup\$

What my function does:

Safely sets object properties with dot notation strings in JavaScript.

That is to say, it allows setting a nested property on an object using a string such as "a.b.c".

For example: set(obj, "a.b.c", value) would be equivalent to a.b.c = value

Some notes on intended behavior

  • When setting a property at a given path, if any portion of that path doesn't exist, it should be created
  • When creating a path portion, if the next key in the path is NOT an integer, the current portion should be created as an object
  • When creating a path portion, if the next key in the path is an integer, the current portion should be created as an array

Why?

I'd like to be able to set deeply nested properties on an object in an environment where I can't be sure that all the properties in the path will always exist and I don't want to deal with the logic of checking for and setting each key manually in more than one place. This allows me to quickly set the needed value without caring if the keys were present previously or not.

/**
 * Set the value for the given object for the given path
 * where the path can be a nested key represented with dot notation
 *
 * @param {object} obj   The object on which to set the given value
 * @param {string} path  The dot notation path to the nested property where the value should be set
 * @param {mixed}  value The value that should be set
 * @return {mixed}
 *
 */
function set(obj, path, value) {
    // protect against being something unexpected
    obj = typeof obj === 'object' ? obj : {};
    // split the path into and array if its not one already
    var keys = Array.isArray(path) ? path : path.split('.');
    // keep up with our current place in the object
    // starting at the root object and drilling down
    var curStep = obj;
    // loop over the path parts one at a time
    // but, dont iterate the last part,
    for (var i = 0; i < keys.length - 1; i++) {
        // get the current path part
        var key = keys[i];

        // if nothing exists for this key, make it an empty object or array
        if (!curStep[key] && !Object.prototype.hasOwnProperty.call(curStep, key)){
            // get the next key in the path, if its numeric, make this property an empty array
            // otherwise, make it an empty object
            var nextKey = keys[i+1];
            var useArray = /^\+?(0|[1-9]\d*)$/.test(nextKey);
            curStep[key] = useArray ? [] : {};
        }
        // update curStep to point to the new level
        curStep = curStep[key];
    }
    // set the final key to our value
    var finalStep = keys[keys.length - 1];
    curStep[finalStep] = value;
};



/** Usage **/

console.log('setting non existant a.b.c to "some value":');
var test = {};
set(test, 'a.b.c', 'some value');
console.log(test);

console.log('updating a.b.c to "some new value":');
set(test, 'a.b.c', 'some new value');
console.log(test);

console.log('setting non existant a.b.0 to "some value":');
var test = {};
set(test, 'a.b.0', 'some value');
console.log(test);

console.log('updating a.b.0 to "some new value":'); 
set(test, 'a.b.0', 'some new value');
console.log(test);

\$\endgroup\$
1
  • 4
    \$\begingroup\$ Just in case you're not aware of it, Dottie may have considered some of the issues raised below - github.com/mickhansen/dottie.js \$\endgroup\$
    – iabw
    May 14, 2018 at 15:03

2 Answers 2

7
\$\begingroup\$

Dangerous

There are so many caveats in this function I would really not allow it in any code base because of potencial unexpected , or misunderstood behaviour. Its just waiting to cause problems in completely unrelated code.

Some points.

  • Some variables should be const they are keys, key, useArray, nextKey, finalStep

  • Don't see why you create a new variable curStep (with very odd name) to hold the current object. Just reuse obj

  • Avoid the name set as it is used as a JavaScript token eg create a setter { set val(blah) {...} } Maybe the name could be assignToPath

  • null is also of type "object" so it will pay to extend the test on the first line to include the null Would be best to throw

  • You currently return undefined. Maybe it would be more helpful to return the object you assigned the new property to.

  • Why use the long version !Object.prototype.hasOwnProperty.call(curStep, key) ?? when !curStep.hasOwnProperty(key) will do the same.

  • If a property is expressly set to undefined your function fails.

const test = { a: undefined }
assignToPath(test, "a.b", "foo" ); //throws but would expect test.a.b = "foo"
  • Objects can be locked via various Object functions Object.freeze, Object.seal, Object.preventExtensions, Object.defineProperties and Object.defineProperty Your function ignores these settings.

  • If a property exists but is not an Object or Array you still attempt to add a property to it.

const test = { a: "blah" }
assignToPath(test, "a.b", "foo" ); // fails
  • This is what I would consider a low level function and as such would be one of the few that need to throw errors rather than fail silently. It should throw at anything that is not as expected. For example if the object to assign a property to is an array, or an object
const test = { a: [] }
assignToPath(test, "a.b", "foo" ); // Maybe this should throw

const test = { a: {} }
assignToPath(test, "a.1", "foo" ); // This should throw as it is unclear what the result should be.
// the case against the above
const test = {}
assignToPath(test, "a.1", "foo" ); // creates an array

Suggestions only.

Adding a settings argument would allow for better control of the behaviour when things get a little ambiguous.

assignToPath({a:[]}, "a.b", "bar", {arrayHaveProps : true});

assignToPath({a:{}}, "a.1", "bar", {allowObjIndex : true});

You can shorten the code if you use a while loop and shift the path as you go.

// simple example of using `while` and `shift` rather than a `for` loop
var obj = {a:{b:{c:"a"}}};
const path = "a.b.c".split(".");
while(path.length > 1){
   obj = obj[path.shift()];
}
obj[path.shift()] = "b";
\$\endgroup\$
4
  • 1
    \$\begingroup\$ I guess set({}, "hasOwnProperty", null) is a reason to use Object.prototype.hasOwnProperty \$\endgroup\$
    – sineemore
    May 14, 2018 at 8:00
  • 1
    \$\begingroup\$ or even Object.create(null).hasOwnProperty() \$\endgroup\$
    – sineemore
    May 14, 2018 at 8:02
  • \$\begingroup\$ @sineemore Your examples are not reason to use the indirect call. How about Object.prototype.hasOwnProperty =()=> {} Now what??? \$\endgroup\$
    – Blindman67
    May 14, 2018 at 9:13
  • \$\begingroup\$ Those examples are reason to use the indirect call, because they come up all the time in the real world. Nobody overwrites Object.prototype.hasOwnProperty on purpose while expecting things to work. \$\endgroup\$
    – Ry-
    Apr 20, 2020 at 6:58
9
\$\begingroup\$
obj = typeof obj === 'object' ? obj : {};

When obj argument points to something other than object you will create one. Like in set("foo", "a.b.c", "Yay!"). I guess you want to return it at function end or it will be lost.

Other notes:

  • /^\+?(0|[1-9]\d*)$/ allows + in integer key inputs
  • you may use isNaN(+nextKey) instead of regular expression, but beware float or signed numbers
  • finalStep is a bit misleading name. You may use key = keys[keys.length - 1] instead.
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.