0

I have a JavaScript object with some nested properties that I want to update based on some conditions. The starting object could be something like:

const options = {
    formatOption: {
        label: 'Model Format',
        selections: {
            name: 'Specific Format',
            value: '12x28',
        }
    },
    heightOption: {
        label: 'Model Height',
        selections: {
            name: 'Specific Height',
            value: '15',
        }
    }
};

I have come up with a solution using Object.keys, reduce and the spread operator, but I would like to know if this is the best / more concise way as of today or if there is a better way. I'm not looking for the most performing option, but for a "best practice" (if there is one) or a more elegant way.

EDIT 30/01/20
As pointed out in the comments by @CertainPerformance my code was mutating the original options variable, so I am changing the line const option = options[key]; to const option = { ...options[key] };. I hope this is correct and that the function is not mutating the original data.

const newObject = Object.keys(options).reduce((obj, key) => {
  const option = { ...options[key] };
  const newVal = getNewValue(option.label); // example function to get new values
    // update based on existence of new value and key
    if (option.selections && option.selections.value && newVal) {
      option.selections.value = newVal;
    }
    return {
      ...obj,
      [key]: option,
    };
}, {});

getNewValue is an invented name for a function that I am calling in order to get an 'updated' version of the value I am looking at. In order to reproduce my situation you could just replace the line const newVal = getNewValue(option.label); with const newVal = "bla bla";

Giorgio Tempesta
  • 1,816
  • 24
  • 32

4 Answers4

1

Since you tagged this q with functional-programming here is a functional approach. Functional Lenses are an advanced FP tool and hence hard to grasp for newbies. This is just an illustration to give you an idea of how you can solve almost all tasks and issues related to getters/setters with a single approach:

// functional primitives

const _const = x => y => x;

// Identity type

const Id = x => ({tag: "Id", runId: x});

const idMap = f => tx =>
  Id(f(tx.runId));

function* objKeys(o) {
  for (let prop in o) {
    yield prop;
  }
}

// Object auxiliary functions

const objSet = (k, v) => o =>
  objSetx(k, v) (objClone(o));

const objSetx = (k, v) => o =>
  (o[k] = v, o);

const objDel = k => o =>
  objDelx(k) (objClone(o));

const objDelx = k => o =>
  (delete o[k], o);

const objClone = o => {
  const p = {};

  for (k of objKeys(o))
    Object.defineProperty(
      p, k, Object.getOwnPropertyDescriptor(o, k));

  return p;
};

// Lens type

const Lens = x => ({tag: "Lens", runLens: x});

const objLens_ = ({set, del}) => k => // Object lens
  Lens(map => ft => o =>
    map(v => {
      if (v === null)
        return del(k) (o);

      else 
        return set(k, v) (o)
    }) (ft(o[k])));

const objLens = objLens_({set: objSet, del: objDel});

const lensComp3 = tx => ty => tz => // lens composition
  Lens(map => ft =>
    tx.runLens(map) (ty.runLens(map) (tz.runLens(map) (ft))));

const lensSet = tx => v => o => // set operation for lenses
  tx.runLens(idMap) (_const(Id(v))) (o);

// MAIN

const options = {
    formatOption: {
        label: 'Model Format',
        selections: {
            name: 'Specific Format',
            value: '12x28',
        }
    },
    heightOption: {
        label: 'Model Height',
        selections: {
            name: 'Specific Height',
            value: '15',
        }
    }
};

const nameLens = lensComp3(
  objLens("formatOption"))
    (objLens("selections"))
      (objLens("name"));

const options_ = lensSet(nameLens) ("foo") (options).runId;

// deep update
console.log(options_);

// reuse of unaffected parts of the Object tree (structural sharing)
console.log(
  options.heightOptions === options_.heightOptions); // true

This is only a teeny-tiny part of the Lens machinery. Functional lenses have the nice property to be composable and to utilize structural sharing for some cases.

  • Definitely too hard to grasp for me at the moment, but thanks for the hint, I'll try to wrap my head around it soon. I have tagged it as `functional-programming` because I thought that using `map`, `filter` and `reduce` and not mutating the original data was enough to call my code 'functional', but maybe it's just the hype around JavaScript – Giorgio Tempesta Jan 30 '20 at 13:44
  • 1
    The `map` you are referring to is the term level part of the `array` Functor. Lenses heavily depend on Functors as well, namely the instances of the `Id` and `Const` type. This is one of the core principles of FP. There are some very general and fundamental concepts, which are reused for various more specific tools. –  Jan 30 '20 at 13:59
  • 1
    @GiorgioTempesta If you are interested, I can recommend the [Mostly Adequate Guide to Functional Programming (in JavaScript)](https://mostly-adequate.gitbooks.io/mostly-adequate-guide/) ebook as a look into functional programming. It does a good job of explaining the concepts and leads you from just using functions, to actual programming *through* functions, composition, algebraic types, etc. The first few chapters will explain how things fit together, to at least give you a taste of FP. It even has exercises at the end of each chapter but you can skip them if you just prefer just an overview. – VLAZ Jan 31 '20 at 07:44
  • 1
    @GiorgioTempesta There is also [another FP guide in the making](https://github.com/kongware/scriptum#functional-programming-course-toc) but it's not finished yet. It's by bob here and I'm really excited to see it finished. It has the introduction concepts to FP down for now. Also, [Composing Software: The Book](https://medium.com/javascript-scene/composing-software-the-book-f31c77fc3ddc) on Medium. I've not read it yet but I trust the author. I personally found it really helpful to look at multiple sources on FP because one might omit a seemingly obvious detail that I'd be lost without. – VLAZ Jan 31 '20 at 07:45
  • 1
    @VLAZ Thanks! I am on it. –  Jan 31 '20 at 18:13
1

If you want to set a value for a nested property in a immutable fashion, then you should consider adopting a library rather than doing it manually.

  • In FP there is the concept of lenses

Ramda provides a nice implementation: https://ramdajs.com/docs/

const selectionsNameLens = R.lensPath(
  ['formatOption', 'selections', 'name'],
);

const setter = R.set(selectionsNameLens);


// ---
const data = {
  formatOption: {
    label: 'Model Format',
    selections: {
      name: 'Specific Format',
      value: '12x28',
    },
  },
  heightOption: {
    label: 'Model Height',
    selections: {
      name: 'Specific Height',
      value: '15',
    },
  },
};

console.log(
  setter('Another Specific Format', data),
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.js" integrity="sha256-xB25ljGZ7K2VXnq087unEnoVhvTosWWtqXB4tAtZmHU=" crossorigin="anonymous"></script>
Hitmands
  • 13,491
  • 4
  • 34
  • 69
0

The first comment from CertainPerformance made me realize that I was mutating the original options variable. My first idea was to make a copy with the spread operator, but the spread operator only makes a shallow copy, so even in my edit I was still mutating the original object.
What I think is a solution is to create a new object with only the updated property, and to merge the two objects at the end of the reducer.

EDIT
The new object also needs to be merged with the original option.selections, otherwise I would still overwrite existing keys at that level (ie I would overwrite option.selections.name).

Here is the final code:

const newObject = Object.keys(options).reduce((obj, key) => {
  const option = options[key];
  const newVal = getNewValue(option.label); // example function to get new values
  const newOption = {}; // create a new empty object
  // update based on existence of new value and key
  if (option.selections && option.selections.value && newVal) {
    // fill the empty object with the updated value,
    // merged with a copy of the original option.selections
    newOption.selections = {
      ...option.selections,
      value: newVal
    };
  }
  return {
    ...obj, // accumulator
    [key]: {
      ...option, // merge the old option
      ...newOption, // with the new one
    },
  };
}, {});
Giorgio Tempesta
  • 1,816
  • 24
  • 32
0

A more concise version that has been suggested to me would be to use forEach() instead of reduce(). In this case the only difficult part would be to clone the original object. One way would be to use lodash's _.cloneDeep(), but there are plenty of options (see here).

Here is the code:

const newObject = _.cloneDeep(options);
Object.keys(newObject).forEach(key => {
    const newVal = getNewValue(newObject[key].label); // example function to get new values
    // update based on existence of new value and key
    if (newObject[key].selections && newObject[key].selections.value && newVal) {
        newObject[key].selections.value = newVal;
    }
});

The only problem is that forEach() changes values that are declared outside of the function, but reduce() can mutate its parameter (as it happened in my original solution), so the problem is not solved by using reduce() alone.

I'm not sure that this is the best solution, but it surely is much more readable for the average developer than my first try or the other solutions.

Giorgio Tempesta
  • 1,816
  • 24
  • 32