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.
- 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' }
.
- 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 id
s. 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:
- Original example
users.reduce((acc, user) => {
if (!user.active) {
return acc;
}
return { ...acc, [user.id]: user.name };
}, {});
- Original buggy proposed improvment
users
.filter((user) => !user.active)
.map((user) => ({ [user.id]: user.name }))
.reduce(Object.assign, {})
- 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 }), {});
- 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.
- How I would write it using
Object.fromEntries
Object.fromEntries(
users
.filter((user) => !user.active)
.map((user) => [user.id, user.name])
);
for of
that Jamie proposed
let inactiveUsers = {};
for (let user of users) {
if (!user.active) {
inactiveUsers[user.id] = user.name;
}
}
- 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
10 | 100 | 1_000 | 10_000 | 100_000 | 200_000 | 500_000 | 1_000_000 | |
---|---|---|---|---|---|---|---|---|
1. Original reduce | 0.052 ms | 0.3 ms | 3.002 ms | 60.703 ms | 15.464 s | 1:02.683 (m:ss.mmm) | N/A | N/A |
2. Buggy solution | 0.087 ms | 2.492 ms | 63.19 ms | 4.935 s | 9:46.291 (m:ss.mmm) | N/A | N/A | N/A |
3. Spread fix | 0.08 ms | 0.513 ms | 0.6 ms | 39.698 ms | 15.965 s | 1:06.773 (m:ss.mmm) | N/A | N/A |
4. Access prop fix | 0.049 ms | 0.155 ms | 0.155 ms | 1.612 ms | 15.596 ms | 18.328 ms | 46.152 ms | 130.901 ms |
5. fromEntires | 0.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 of | 0.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 for | 0.055 ms | 0.046 ms | 0.247 ms | 0.682 ms | 14.649 ms | 10.656 ms | 21.901 ms | 53.893 ms |