1

I want to subtract the values of two objects of the exact same structure. Although one answer exists here, it's limited to objects of no-depth. In my case, I'm looking for a robust solution that would allow subtracting objects of any depth, as long as they're of the same structure.

Example

Consider the following two objects, earthData2022 and earthData2050:

const earthData2022 = {
  distanceFromSun: 149280000,
  continents: {
    asia: {
      area: 44579000,
      population: 4560667108,
      countries: { japan: { temperature: 62.5 } },
    },
    africa: { area: 30370000, population: 1275920972 },
    europe: { area: 10180000, population: 746419440 },
    america: { area: 42549000, population: 964920000 },
    australia: { area: 7690000, population: 25925600 },
    antarctica: { area: 14200000, population: 5000 },
  },
};

const earthData2050 = {
  distanceFromSun: 149280000,
  continents: {
    asia: {
      area: 44579000,
      population: 4560767108,
      countries: { japan: { temperature: 73.6 } },
    },
    africa: { area: 30370000, population: 1275960972 },
    europe: { area: 10180000, population: 746419540 },
    america: { area: 42549000, population: 964910000 },
    australia: { area: 7690000, population: 25928600 },
    antarctica: { area: 14200000, population: 5013 },
  },
};

Please note that both objects have:

  • exact same structure
  • all values are numbers, either integers or decimals. There are no strings or booleans, nor arrays.

I want to subtract: earthData2050 - earthData2022 to get a new object:

// desired output
// continents' areas aren't expected to change so their diff is `0`
// likewise, the distance of earth from sun
const earthDataDiff = {
  distanceFromSun: 0,
  continents: {
    asia: {
      area: 0,
      population: 100000,
      countries: { japan: { temperature: 11.1 } },
    },
    africa: { area: 0, population: 40000 },
    europe: { area: 0, population: 100 },
    america: { area: 0, population: -10000 },
    australia: { area: 0, population: 3000 },
    antarctica: { area: 0, population: 13 },
  },
};

As mentioned above, it's tempting to use the sweet answer given here:

function mySub(x, y) {
  return Object.keys(x).reduce((a, k) => {
    a[k] = x[k] - y[k];
    return a;
  }, {});
}

However, when calling mySub() we get this unsurprising output:

mySub(earthData2050, earthData2022)
// {"distanceFromSun":0,"continents":null}

My question, therefore, is how I can recursively subtract all entries, no matter how deep, provided that the objects have the same structure. Also, as I'm running this code on Node, I'm happy to utilize any new ECMAScript feature that may come in handy.

Emman
  • 3,695
  • 2
  • 20
  • 44

4 Answers4

2

recursion is your friend here

const earthData2022 = {
  distanceFromSun: 149280000,
  continents: {
    asia: {
      area: 44579000,
      population: 4560667108,
      countries: { japan: { temperature: 62.5 } },
    },
    africa: { area: 30370000, population: 1275920972 },
    europe: { area: 10180000, population: 746419440 },
    america: { area: 42549000, population: 964920000 },
    australia: { area: 7690000, population: 25925600 },
    antarctica: { area: 14200000, population: 5000 },
  },
};

const earthData2050 = {
  distanceFromSun: 149280000,
  continents: {
    asia: {
      area: 44579000,
      population: 4560767108,
      countries: { japan: { temperature: 73.6 } },
    },
    africa: { area: 30370000, population: 1275960972 },
    europe: { area: 10180000, population: 746419540 },
    america: { area: 42549000, population: 964910000 },
    australia: { area: 7690000, population: 25928600 },
    antarctica: { area: 14200000, population: 5013 },
  },
};


function mySub(x, y) {
  const result = {}
  Object.keys(x).forEach((key) => {
    if (typeof x[key] === 'number') {
      result[key] = x[key] - y[key]
    } else {
      result[key] = mySub(x[key], y[key])
    }
  });
  return result;
}

console.log(mySub(earthData2050, earthData2022));
Nestoro
  • 787
  • 6
  • 17
2

Declarative/functional solution:

const difference = (obj1, obj2) => Object.entries(obj1).reduce((t, [key, value]) => {
    const obj2Value = obj2[key];
    return {
        ...t,
        [key]: typeof value === "object" ?
            difference(value, obj2Value) :
            value - obj2Value
    };
}, {});

Explaination

Object.entries converts the object to a two-dimensional array of key value pairs. Using array.reduce to iterate over the pairs, it can reduce the array to an object. An analogy of this would be when you're reducing a broth to a sauce while cooking. If the value of the property is an object, the resulting property should be the difference of the difference of the sub-object (recursion). If not, it has to be a number and can therefore be subtracted.

Further reading:

Asplund
  • 2,254
  • 1
  • 8
  • 19
  • Thanks! Could you please explain a bit the `...t`? I understand this is a spread operator, but what is the action being done with it? – Emman Apr 20 '22 at 12:27
  • 1
    @Emman certainly, the spread operator in this case ensures that it combines the old value of the object with the new key. It basically constructs a new object by spreading out the old value into a new object which also contains the new property. – Asplund Apr 20 '22 at 12:29
2

@Scott's answer is remarkably efficient and elegant. In his post he comments -

This only works if your note is correct, that the two objects have identical structures and that leaf nodes are all numbers. We'd have to get more sophisticated if we wanted to handle other cases.

I wanted to share what that would look like. Here we write objDiff as a specialization of zipMap -

const objDiff = zipMap((p, q) =>
  is(p, Number) && is(q, Number)
    ? p - q
    : { error: "cannot compute", left: p, right: q }
)

Where is and zipMap is defined as -

const is = (t, T) => t?.constructor === T

const zipMap = f => (p, q) =>
// Object
  is(p, Object) && is(q, Object)
    ? unique(Object.keys(p), Object.keys(q))
        .reduce((r, k) => Object.assign(r, ({ [k]: zipMap(f)(p[k], q[k]) })), {})
// Array
: is(p, Array) && is(q, Array)
    ? unique(p.keys(), q.keys())
        .map(k => zipMap(f)(p[k], q[k]))
// Else
: f(p, q)

Which depends on unique -

const unique = (p, q) =>
  Array.from(new Set([...p, ...q]))

To demo this, I added a sampleArray property to each object and a hello: "world" key pair to one. Run the code below to objDiff now works on asymmetric inputs and mixed values types -

const is = (t, T) => t?.constructor === T

const unique = (p, q) =>
  Array.from(new Set([...p, ...q]))

const zipMap = f => (p, q) =>
// Object
  is(p, Object) && is(q, Object)
    ? unique(Object.keys(p), Object.keys(q))
        .reduce((r, k) => Object.assign(r, ({ [k]: zipMap(f)(p[k], q[k]) })), {})
// Array
: is(p, Array) && is(q, Array)
    ? unique(p.keys(), q.keys())
        .map(k => zipMap(f)(p[k], q[k]))
// Else
: f(p, q)

const objDiff = zipMap((p, q) =>
  is(p, Number) && is(q, Number)
    ? p - q
    : { error: "cannot compute", left: p, right: q }
)

const earthData2022 = {sampleArray: [10, 20, 30], distanceFromSun: 14928e4, continents: {asia: {area: 44579e3, population: 4560667108, countries: {japan: {temperature: 62.5}}}, africa: {area: 3037e4, population: 1275920972}, europe: {area: 1018e4, population: 746419440}, america: {area: 42549e3, population: 96492e4}, australia: {area: 769e4, population: 25925600}, antarctica: {area: 142e5, population: 5e3}}}
const earthData2050 = {sampleArray: [9, 40, 30, 100], distanceFromSun: 14928e4, continents: {asia: {area: 44579e3, population: 4560767108, countries: {japan: {temperature: 73.6}}}, africa: {area: 3037e4, population: 1275960972}, europe: {area: 1018e4, population: 746419540}, america: {area: 42549e3, population: 96491e4}, australia: {area: 769e4, population: 25928600}, antarctica: {area: 142e5, population: 5013, hello: "world"}}}

console.log(objDiff(earthData2050, earthData2022))
.as-console-wrapper {max-height: 100% !important; top: 0}
{
  "sampleArray": [
    -1,
    20,
    0,
    {
      "error": "cannot compute",
      "left": 100,
      "right": undefined
    }
  ],
  "distanceFromSun": 0,
  "continents": {
    "asia": {
      "area": 0,
      "population": 100000,
      "countries": {
        "japan": {
          "temperature": 11.099999999999994
        }
      }
    },
    "africa": {
      "area": 0,
      "population": 40000
    },
    "europe": {
      "area": 0,
      "population": 100
    },
    "america": {
      "area": 0,
      "population": -10000
    },
    "australia": {
      "area": 0,
      "population": 3000
    },
    "antarctica": {
      "area": 0,
      "population": 13,
      "hello": {
        "error": "cannot compute",
        "left": "world",
        "right": undefined
      }
    }
  }
}
Mulan
  • 129,518
  • 31
  • 228
  • 259
1

Here's a pretty simple recursive approach:

const objDiff = (x, y) => 
  Object .fromEntries (Object .entries (x) .map (
    ([k, v]) => [k, Object (v) === v ? objDiff (v, y [k]) : v - y [k]]
  ))

const earthData2022 = {distanceFromSun: 14928e4, continents: {asia: {area: 44579e3, population: 4560667108, countries: {japan: {temperature: 62.5}}}, africa: {area: 3037e4, population: 1275920972}, europe: {area: 1018e4, population: 746419440}, america: {area: 42549e3, population: 96492e4}, australia: {area: 769e4, population: 25925600}, antarctica: {area: 142e5, population: 5e3}}}
const earthData2050 = {distanceFromSun: 14928e4, continents: {asia: {area: 44579e3, population: 4560767108, countries: {japan: {temperature: 73.6}}}, africa: {area: 3037e4, population: 1275960972}, europe: {area: 1018e4, population: 746419540}, america: {area: 42549e3, population: 96491e4}, australia: {area: 769e4, population: 25928600}, antarctica: {area: 142e5, population: 5013}}}

console .log (objDiff (earthData2050, earthData2022))
.as-console-wrapper {max-height: 100% !important; top: 0}

We take the entries in your first object and map them to new entries with the same key and a value which is either the result of a recursive call or the subtraction, based on whether the first value is an object or a number. Then we use Object .fromEntries to rebuild a new object.

This only works if your note is correct, that the two objects have identical structures and that leaf nodes are all numbers. We'd have to get more sophisticated if we wanted to handle other cases.

Scott Sauyet
  • 49,207
  • 4
  • 49
  • 103
  • _"We'd have to get more sophisticated if we wanted to handle other cases."_ I added a supplement to this terrific answer to show what that would look like :D – Mulan Apr 23 '22 at 14:01