DEV Community

Paige Niedringhaus
Paige Niedringhaus

Posted on • Originally published at paigeniedringhaus.com on

Merge JavaScript Objects in an Array with Different Defined Properties

Convergence of gold threads

Introduction

At work recently, I encountered an interesting problem: I needed to merge multiple JavaScript objects together in an array based on their matching keys.

It sounds relatively straightforward at first, but there's more to the story that prevented me using the spread operator or Object.assign() to combine these objects.

  1. Each of these objects might share a common key - it wasn't guaranteed, and
  2. Although they all had the same properties in each object, not all of these properties were defined in every object - the object could have property: value or it could be property: undefined.

I needed to combine all the properties of objects that shared a common key along with their defined values into a new single object. And to make it just a smidge more difficult I needed it to work in TypeScript. 😅

So today, I'll show you how to use JavaScript's reduce() function to merge two (or more) objects in an array with different defined properties.


Background: motion.qo and air.qo events

For those of you curious as to when this sort of thing comes up in real life, here's a little more context about my situation.

I work as a software engineer at an Internet of Things (IoT) startup named Blues Wireless. Our main mission is to make IoT development simpler, regardless of if there's a reliable Internet connection or not. Blues does this via Notecards - prepaid cellular devices that can be embedded into any IoT device "on the edge" to transmit that sensor data as JSON to a secure cloud: Notehub.

In addition to producing this hardware and providing the secure cloud connection to send IoT data to, we also build software applications our users can connect their devices to and see the sensor data in charts and graphs.

Sample charts displaying sensor data

Here's an example of the charts displaying various sensor data.

One of the web apps we were building involved sensors that reported two types of data:

  • air quality readings like temperature, humidity, sensor voltage, and pressure, and
  • motion readings like current count of motions detected and total motions detected over time.

So a single sensor is generating two different JSON payloads that are sent from a Notecard to Notehub, then Notehub routes them to our web app to get rendered in the charts.

Here's a sample of some of the events delivered to the app.

Example sensor events

const sensorEvents = [
  {
    sensorId: 'abc',
    id: '1',
    humidity: undefined,
    pressure: undefined,
    temperature: undefined,
    voltage: undefined,
    count: 3,
    total: 32,
    lastActivity: '2022-02-10T00:41:11Z'
  },
  {
    sensorId: 'abc',
    id: '2',
    humidity: 17.609375,
    pressure: 96.768734,
    temperature: 26.703125,
    voltage: 3.24,
    count: undefined,
    total: undefined,
    lastActivity: '2022-02-15T21:59:18Z'
  },
  {
    sensorId: 'def',
    id: '3',
    humidity: undefined,
    pressure: undefined,
    temperature: undefined,
    voltage: undefined,
    count: 866,
    total: 1776,
    lastActivity: '2022-02-15T22:00:03Z'
  }
]
Enter fullscreen mode Exit fullscreen mode

As you can see, the first two objects in the array share the same sensorId of abc, but they have different defined properties.

The first object has motion properties (and undefined air quality properties), the second object has defined air quality properties (and undefined motion properties), and the third object has a different sensorId entirely; that is a motion reading from another sensor.

Which brought me to my problem: how to combine the objects with the same sensorId and get all the defined properties from each object.

So, let's get to it.

Merge like objects with a mergeObject() function and reduce()

With the amount of times I've said "merge" and "combine" in this post, you may already have figured out that the JavaScript array function reduce() might come into play here, and it does.

At its most basic, reduce() goes through each element in an array and adds it to an accumulator of all the values preceding it, until just one value is returned at the end. The same thing can be done with an array of objects, with just a little more logic.

If you'd like a more thorough explanation of reduce(), I encourage you to check out the Mozilla documentation.

Their examples are great, and I still visit it regularly for all sorts of topics.

Below is the TypeScript-flavored solution I came up with to take an array of objects, find the ones that share a common key, and return a new array with those objects combined.

interface HasSensorId {
  sensorId: string;
}

// merge objects with different defined properties into a single obj
const mergeObject = <V>(A: any, B: any): V => {
  let res: any = {};
  Object.keys({ ...A, ...B }).map((key) => {
    res[key] = B[key] || A[key];
  });
  return res as V;
};

// use Map functions `get`, `set`, and `values`
// take each event, and make it into a new Map() obj where sensorId is the key
// regardless of if there's already a value for that key, run the merge function
// set the key and the new merge var as the key, value for the map
const reducer = <V extends HasSensorId>(
  groups: Map<string, V>,
  event: V
) => {
  const key = event.sensorId;
  const previous = groups.get(key);
  const merged: V = mergeObject(previous || {}, event);
  groups.set(key, merged);
  return groups;
};

// run the sensor events through the reducer and then pull only their values into a new Map iterator obj
const reducedEventsIterator = sensorEvents
  .reduce(reducer, new Map())
  .values();

// transform the Map iterator obj back into an array
const reducedEvents = Array.from(reducedEventsIterator);
Enter fullscreen mode Exit fullscreen mode

Ok, I realize there's quite a few functions above, so an explanation is in order.

HasSensorId

The HasSensorId interface is pretty direct: it means that wherever this interface is referenced, the object's must have a string of sensorId among the properties.

Now let's walk through the rest of these functions one by one.

mergeObject()

const mergeObject = <V>(A: any, B: any): V => {
  let res: any = {};
  Object.keys({ ...A, ...B }).map((key) => {
    res[key] = B[key] || A[key];
  });
  return res as V;
};
Enter fullscreen mode Exit fullscreen mode

This mergeObject() function is the one that actually does the combining of objects.

The A and B objects that this function takes in are the two objects with a common sensorId property, and the res variable will be the newly returned object of their properties (and values) combined.

The first thing that happens is the two objects are combined using the spread operator, and then just their keys are extracted into its own array using Object.keys(). If you passed the sample sensorEvents array into this function, the Object.keys() array would look like this:

[
  'sensorId',
  'humidity',
  'pressure',
  'temperature',
  'voltage',
  'count',
  'total',
  'lastActivity'
]
Enter fullscreen mode Exit fullscreen mode

After the keys are extracted, each key is added to the new res object as a key, and if the B object's value at that key exists (B[key]), it's added as the value for the res object, otherwise, the A object's value is added instead (even if it's undefined).

The final res object returned looks like this:

{
  sensorId: 'abc',
  humidity: 17.609375,
  pressure: 96.768734,
  temperature: 26.703125,
  voltage: 3.24,
  count: 3,
  total: 32,
  lastActivity: '2022-02-15T21:59:18Z'
}
Enter fullscreen mode Exit fullscreen mode

And finally the V and any references are part of the TypeScript implementation. From looking at the rest of the code, we can see V is an extension of the HasSensorId interface, which means any event object will have sensorId as a property. And any means the objects passed in to the function could have any properties (or not), so it's flexible.

Ok, on to the next function!

reducer()

const reducer = <V extends HasSensorId>(
  groups: Map<string, V>,
  event: V
) => {
  const key = event.sensorId;
  const previous = groups.get(key);
  const merged: V = mergeObject(previous || {}, event);
  groups.set(key, merged);
  return groups;
};
Enter fullscreen mode Exit fullscreen mode

The reducer() function makes excellent use of true Map objects in JavaScript. It uses the HasSensorId interface as well, ensuring that every object will have a sensorId property.

Each event object passed into the function, has its sensorId turned into a key variable. The previous variable relies on Map's get() function to fetch any other Map objects that have that same sensorId key, and if another object is found, the new event is combined with it when the mergeObject() function is called. If there's no matching previous object, an empty object is passed to mergeObject(), and a new merged variable is still returned.

Finally, the Map set() function adds the key and merged variable to the groups Map object.

If our three sensorEvents are run through this function, the final groups object that emerges looks like this:

Map(2) {
  'abc' => {
    sensorId: 'abc',
    humidity: 17.609375,
    pressure: 96.768734,
    temperature: 26.703125,
    voltage: 3.24,
    count: 3,
    total: 32,
    lastActivity: '2022-02-15T21:59:18Z'
  },
  'def' => {
    sensorId: 'def',
    humidity: undefined,
    pressure: undefined,
    temperature: undefined,
    voltage: undefined,
    count: 866,
    total: 1776,
    lastActivity: '2022-02-15T22:00:03Z'
  }
}
Enter fullscreen mode Exit fullscreen mode

And so we move on to the penultimate part of this code example, reducedEventsIterator.

reducedEventsIterator

const reducedEventsIterator = sensorEvents
  .reduce(reducer, new Map())
  .values();
Enter fullscreen mode Exit fullscreen mode

The reducedEventsIterator variable is where we call reduce() on the sensorEvents array. The reducer() function gets passed as the first argument each event in the array must run through, and a new Map() object is passed as the accumulator object where they will all be gathered together at the end.

Before the Object.values() method is chained on to the end, the array looks like this:

[
  [
    'abc',
    {
      sensorId: 'abc',
      humidity: 17.609375,
      pressure: 96.768734,
      temperature: 26.703125,
      voltage: 3.24,
      count: 3,
      total: 32,
      lastActivity: '2022-02-15T21:59:18Z'
    }
  ],
  [
    'def',
    {
      sensorId: 'def',
      humidity: undefined,
      pressure: undefined,
      temperature: undefined,
      voltage: undefined,
      count: 866,
      total: 1776,
      lastActivity: '2022-02-15T22:00:03Z'
    }
  ]
]
Enter fullscreen mode Exit fullscreen mode

After .values() is called, the final reducedEventsIterator looks more like this:

[Map Iterator] {
  {
    sensorId: 'abc',
    humidity: 17.609375,
    pressure: 96.768734,
    temperature: 26.703125,
    voltage: 3.24,
    count: 3,
    total: 32,
    lastActivity: '2022-02-15T21:59:18Z'
  },
  {
    sensorId: 'def',
    humidity: undefined,
    pressure: undefined,
    temperature: undefined,
    voltage: undefined,
    count: 866,
    total: 1776,
    lastActivity: '2022-02-15T22:00:03Z'
  }
}
Enter fullscreen mode Exit fullscreen mode

Which brings us to the final step: transforming this Map of objects back to a proper JavaScript array of objects (and all the benefits that entails - arrays are much easier to manipulate).

The final merged array: reducedEvents

const reducedEvents = Array.from(reducedEventsIterator);
Enter fullscreen mode Exit fullscreen mode

Arguably the simplest part of this whole tutorial, the Map of objects is transformed back into an array simply by passing it into the Array.from() method.

Beware Array.from() for copying deep objects

If you have deeply nested objects in some sort of array-like structure that you need to copy, Array.from() probably won't work for you. It only makes shallow-copied instances of arrays.

So if we pass the example sensor events displayed above into our reducedEventsIterator() function here, this is what the final result looks like:

[
  {
    sensorId: 'abc',
    humidity: 17.609375,
    pressure: 96.768734,
    temperature: 26.703125,
    voltage: 3.24,
    count: 3,
    total: 32,
    lastActivity: '2022-02-15T21:59:18Z'
  },
  {
    sensorId: 'def',
    humidity: undefined,
    pressure: undefined,
    temperature: undefined,
    voltage: undefined,
    count: 866,
    total: 1776,
    lastActivity: '2022-02-15T22:00:03Z'
  }
]
Enter fullscreen mode Exit fullscreen mode

And there you have it: an array of freshly merged objects with all available properties present.


Conclusion

One reason I enjoy being a software engineer is because of all the interesting problems I get to solve regularly. Lately, when I was building a web app to display data from IoT sensors at work, I ran into a more unique problem: taking an array of sensor events and merging the events together if they belonged to the same sensor. The catch was, all the events had the same properties, but depending on which event you were looking at, half the properties had values of undefined.

So instead of being able to use the spread operator or Object.assign() to get these events merged into one object, I had to get more creative with a few extra functions and the reduce() method. But it worked - even with TypeScript in the mix.

Check back in a few weeks — I’ll be writing more about JavaScript, React, IoT, or something else related to web development.

If you’d like to make sure you never miss an article I write, sign up for my newsletter here: https://paigeniedringhaus.substack.com

Thanks for reading. I hope this little bit of JavaScript helps you out in future - who'd have thought merging objects could be so interesting, right?


References & Further Resources

Top comments (0)