ECMAScript proposal: grouping Arrays via .group() and .groupToMap()

[2022-01-21] dev, javascript, es proposal
(Ad, please don’t block)

This blog post describes the ECMAScript proposal “Array grouping” by Justin Ridgewell.

Grouping Arrays  

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:

  • Input: an Array
  • Output: groups. Each group has a group key and an Array with group members.

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”).

How to choose between .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.

Use cases for grouping  

These are three common use cases for group Arrays:

  • Handling cases:
    • There is a fixed set of group keys that we know ahead of time.
    • We want one Array with values per case.
  • Grouping by property value:
    • We get an arbitrary set of group keys.
    • We are interested in [group key, group members] pairs.
  • Counting members of groups:
    • This use case is similar to grouping by property value, but we are only interested in how many input Array elements have a given property value, not in which elements they are.

Next, we’ll see an example for each use case and which of the two grouping methods is a better fit.

Handling cases  

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).

Grouping by property value  

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.

Counting elements  

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.

What if each input Array element can belong to multiple groups?  

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']},
      ]
    ],
  ])
);

Implementations  

Implementing grouping ourselves  

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;
};

Libraries  

Acknowledgements