Reduce

A quick take on reducing arrays to objects.

1044 words, 6 min read

I've opened Twitter today to see this

Telling everybody that it's better to use .filter .map and .reduce that doing everything in one go.

Then Jamie tweeted

Concerned about performance.

But it's cheap to talk showing some little examples and not measuring anything. My procrastination hit me and I felt the urge to measure it!

Sadly, the jsperf.com isn't working. So I needed to find a different way. Fortunately, we now have console.time so I've prepared small examples to test it.

## Bug

Before we start there's one thing we should mention. Theres a bug in the tweet. The proposed solution

users
  .filter((user) => !user.active)
  .map((user) => ({ [user.id]: user.name }))
  .reduce(Object.assign, {});

If run on this example data set

let users = [
  { id: 1, active: false, name: 'Hank' },
  { id: 2, active: false, name: 'John' },
];

will be unexpetedly

{ '0': { '1': 'Hank' }, '1': { '2': 'John' }, '2': 'John' }

In order to understand why is that we need to look at reduce documention. There we will notice that the syntax can be reduce((accumulator, currentValue, index, array) => { ... }, initialValue) and that last argument is the culprit here.

You see, Object.assign as its docs page say

copies all enumerable own properties from one or more source objects to a target object

enumerable own properties

Let's see what happens here.

  1. The first call of the reduce would have the following argument passed to it:
  reduce({}, { 1: 'Hank' }, 0, [{ 1: 'Hank' }, { 2: 'John' }])

And the same arguments would be passed to Object.assign.

The first one is an initial empty object as the target. The second one is the first result from .map.

Until here we would be good, the result would be simply {1: 'Hank'}. But we still have few arguments left. The next one is the array index 0. Numbers don't have enumerable properties so we're good, the result would still, be {1: 'Hank'}. And then there's the last argument, the array. We can check what are array enumerable properties with Object.getOwnPropertyDescriptors.

the result is:

{
  '0': {
    value: { '1': 'Hank' },
    writable: true,
    enumerable: true,
    configurable: true
  },
  '1': {
    value: { '2': 'John' },
    writable: true,
    enumerable: true,
    configurable: true
  },
  length: { value: 2, writable: true, enumerable: false, configurable: false }
}

Two enumerable properties. This is what we're getting as a result

{ '0': { '1': 'Hank' }, '1': { '2': 'John' } }

Notice how we shadowing the previous 1: 'Hank' with '1': { '2': 'John' }.

  1. Then the .reduce would carry on and applied the second set of arguments that would be
.reduce(
  { '0': { '1': 'Hank' }, '1': { '2': 'John' } },
  { '2': 'John' },
  1,
  [{ 1: 'Hank' }, { 2: 'John' }]
)

And the same things would happen. But this time we also get a surprising result.

{ '0': { '1': 'Hank' }, '1': { '2': 'John' }, '2': 'John' }

We have doubled the last entry, but somehow it got flattened. Well, this time it's just the fault of the example. This is because we have two facts. First I have simple numbers as ids. Second Object.assign applies entries from left to right shadowing the ones with overlapping property names. There was not a property name called 2 in the resulting object so it was applied, and the array enumerable properties don't have a property called 2 either so it wasn't shadowed by anything.

### Conclusion

As Jake Archibald was warning don't use functions as callbacks unless they're designed for it and Object.assign wasn't.

## Performace

Let's bet back to check the performance. I've prepared a gist with few examples. There I have a few examples:

  1. Original example
users.reduce((acc, user) => {
  if (!user.active) {
    return acc;
  }
  return { ...acc, [user.id]: user.name };
}, {});
  1. Original buggy proposed improvment
users
  .filter((user) => !user.active)
  .map((user) => ({ [user.id]: user.name }))
  .reduce(Object.assign, {})
  1. Fixed with object spread and dropped .map because it wasn't needed
users
  .filter((user) => !user.active)
  .reduce((acc, user) => ({ ...acc, [user.id]: user.name }), {});
  1. Fixed with object property assign, still witout uneeded .map
users
  .filter((user) => !user.active)
  .reduce((acc, user) => {
    acc[user.id] = user.name;
    return acc;
  }, {});

Notice how we must have explicit return here.

  1. How I would write it using Object.fromEntries
Object.fromEntries(
  users
    .filter((user) => !user.active)
    .map((user) => [user.id, user.name])
);
  1. for of that Jamie proposed
let inactiveUsers = {};
for (let user of users) {
  if (!user.active) {
    inactiveUsers[user.id] = user.name;
  }
}
  1. And classical for loop, just because
let inactiveUsers = {};
for (let i = 0, length = users.length; i < length; i++) {
  let user = users[i];
  if (!user.active) {
    inactiveUsers[user.id] = user.name;
  }
}

I've run these examples with variant array size in Node to compare how do they perform.

### Results

101001_00010_000100_000200_000500_0001_000_000
1. Original reduce0.052
ms
0.3
ms
3.002
ms
60.703
ms
15.464
s
1:02.683
(m:ss.mmm)
N/AN/A
2. Buggy solution0.087
ms
2.492
ms
63.19
ms
4.935
s
9:46.291
(m:ss.mmm)
N/AN/AN/A
3. Spread fix0.08
ms
0.513
ms
0.6
ms
39.698
ms
15.965
s
1:06.773
(m:ss.mmm)
N/AN/A
4. Access prop fix0.049
ms
0.155
ms
0.155
ms
1.612
ms
15.596
ms
18.328
ms
46.152
ms
130.901
ms
5. fromEntires0.137
ms
0.145
ms
0.183
ms
2.134
ms
24.653
ms
45.031
ms
109.993
ms
382.744
ms
6. for of0.113
ms
0.049
ms
0.158
ms
0.883
ms
17.904
ms
19.687
ms
51.075
ms
72.917
ms
7. calssical for0.055
ms
0.046
ms
0.247
ms
0.682
ms
14.649
ms
10.656
ms
21.901
ms
53.893
ms