.group()
and .groupToMap()
This blog post describes the ECMAScript proposal “Array grouping” by Justin Ridgewell.
The proposal introduces two new Array methods:
array.group(callback, thisArg?)
array.groupToMap(callback, thisArg?)
These are their type signatures:
Array<Elem>.prototype.group<GroupKey extends (string|symbol)>(
callback: (value: Elem, index: number, array: Array<Elem>) => GroupKey,
thisArg?: any
): {[k: GroupKey]: Array<Elem>}
Array<Elem>.prototype.groupToMap<GroupKey>(
callback: (value: Elem, index: number, array: Array<Elem>) => GroupKey,
thisArg?: any
): Map<GroupKey, Array<Elem>>
Both methods group Arrays:
The algorithm iterates over the Array. For each Array element, it asks its callback for a group key and adds the element to the corresponding group.
Therefore: Concatenating all group members is equal to the input Array – if we ignore the order of elements.
The two methods differ in how they represent the groups:
.group()
stores the groups in an object: Group keys are stored as property keys. Group members are stored as property values..groupToMap()
stores the groups in a Map: Group keys are stored as Map keys. Group members are stored as Map values.In the next section, we’ll look into use cases for grouping and which method to use for which use case.
This is a first example of grouping:
const groupBySign = (nums) => nums.group(
(elem) => {
if (elem < 0) {
return 'negative';
} else if (elem === 0) {
return 'zero';
} else {
return 'positive';
}
}
);
assert.deepEqual(
groupBySign([0, -5, 3, -4, 8, 9]),
{
__proto__: null,
zero: [0],
negative: [-5, -4],
positive: [3, 8, 9],
}
);
The prototype of the object returned by .group()
is null
. That makes it a better dictionary because no properties are inherited and property __proto__
does not have any special behavior (for details see “JavaScript for impatient programmers”).
.group()
and .groupToMap()
? How do we choose between the two grouping methods?
If we want to destructure (and we know the group keys ahead of time), we use .group()
:
const {negative, positive} = groupBySign([0, -5, 3, -4, 8, 9]);
Otherwise, using a Map has the benefit that keys are not limited to strings and symbols. We’ll see .groupToMap()
in action soon.
These are three common use cases for group Arrays:
Next, we’ll see an example for each use case and which of the two grouping methods is a better fit.
The Promise combinator Promise.allSettled()
returns Arrays such as the following one:
const settled = [
{ status: 'rejected', reason: 'Jhon' },
{ status: 'fulfilled', value: 'Jane' },
{ status: 'fulfilled', value: 'John' },
{ status: 'rejected', reason: 'Jaen' },
{ status: 'rejected', reason: 'Jnoh' },
];
We can group the Array elements as follows:
const {fulfilled, rejected} = settled.group(x => x.status); // (A)
// Handle fulfilled results
assert.deepEqual(
fulfilled,
[
{ status: 'fulfilled', value: 'Jane' },
{ status: 'fulfilled', value: 'John' },
]
);
// Handle rejected results
assert.deepEqual(
rejected,
[
{ status: 'rejected', reason: 'Jhon' },
{ status: 'rejected', reason: 'Jaen' },
{ status: 'rejected', reason: 'Jnoh' },
]
);
For this use case, .group()
works better because we can use destructuring (line A).
In the next example, we’d like to group persons by country:
const persons = [
{ name: 'Louise', country: 'France' },
{ name: 'Felix', country: 'Germany' },
{ name: 'Ava', country: 'USA' },
{ name: 'Léo', country: 'France' },
{ name: 'Oliver', country: 'USA' },
{ name: 'Leni', country: 'Germany' },
];
assert.deepEqual(
persons.groupToMap((person) => person.country),
new Map([
[
'France',
[
{ name: 'Louise', country: 'France' },
{ name: 'Léo', country: 'France' },
]
],
[
'Germany',
[
{ name: 'Felix', country: 'Germany' },
{ name: 'Leni', country: 'Germany' },
]
],
[
'USA',
[
{ name: 'Ava', country: 'USA' },
{ name: 'Oliver', country: 'USA' },
]
],
])
);
For this use case, .groupToMap()
is a better choice because we can use arbitrary keys in Maps whereas in objects, keys are limited to strings and symbols.
In the following example, we count how often each word occurs in a given text:
function countWords(text) {
const words = text.split(' ');
const groupMap = words.groupToMap((word) => word);
return new Map(
Array.from(groupMap) // (A)
.map(([word, wordArray]) => [word, wordArray.length])
);
}
assert.deepEqual(
countWords('knock knock chop chop buffalo buffalo buffalo'),
new Map([
['buffalo', 3],
['chop', 2],
['knock', 2],
])
);
We are only interested in the sizes of the groups. In line A, we take a detour via an Array because Maps don’t have a method .map()
.
Once again, we use .groupToMap()
because Maps can have arbitrary keys.
We cannot use the grouping methods if each element of the input Array can belong to multiple groups. Then we have to write our own grouping function – for example:
function multiGroupToMap(arr, callback, thisArg) {
const result = new Map();
for (const [index, elem] of arr.entries()) {
const groupKeys = callback.call(thisArg, elem, index, this);
for (const groupKey of groupKeys) {
let group = result.get(groupKey);
if (group === undefined) {
group = [];
result.set(groupKey, group);
}
group.push(elem);
}
}
return result;
}
function groupByTag(objs) {
return multiGroupToMap(objs, (obj) => obj.tags);
}
const articles = [
{title: 'Sync iteration', tags: ['js', 'iter']},
{title: 'Promises', tags: ['js', 'async']},
{title: 'Async iteration', tags: ['js', 'async', 'iter']},
];
assert.deepEqual(
groupByTag(articles),
new Map([
['js', [
{title: 'Sync iteration', tags: ['js', 'iter']},
{title: 'Promises', tags: ['js', 'async']},
{title: 'Async iteration', tags: ['js', 'async', 'iter']},
]
],
['iter', [
{title: 'Sync iteration', tags: ['js', 'iter']},
{title: 'Async iteration', tags: ['js', 'async', 'iter']},
]
],
['async', [
{title: 'Promises', tags: ['js', 'async']},
{title: 'Async iteration', tags: ['js', 'async', 'iter']},
]
],
])
);
These are simple implementations of the two grouping methods:
Array.prototype.group = function (callback, thisArg) {
const result = Object.create(null);
for (const [index, elem] of this.entries()) {
const groupKey = callback.call(thisArg, elem, index, this);
if (! (groupKey in result)) {
result[groupKey] = [];
}
result[groupKey].push(elem);
}
return result;
};
Array.prototype.groupToMap = function (callback, thisArg) {
const result = new Map();
for (const [index, elem] of this.entries()) {
const groupKey = callback.call(thisArg, elem, index, this);
let group = result.get(groupKey);
if (group === undefined) {
group = [];
result.set(groupKey, group);
}
group.push(elem);
}
return result;
};
.group()
and .groupToMap()
._.groupBy()
is equivalent to array.group()
.