Symbol.iterator – tsmx https://tsmx.net pragmatic IT Fri, 20 Aug 2021 21:08:37 +0000 en-US hourly 1 https://wordpress.org/?v=6.6.1 https://tsmx.net/wp-content/uploads/2020/09/cropped-tsmx-klein_transparent-2-32x32.png Symbol.iterator – tsmx https://tsmx.net 32 32 JavaScript Map: sorted for-loop with a custom iterator https://tsmx.net/javascript-map-sorted-for-loop/ Tue, 01 Jun 2021 20:46:46 +0000 https://tsmx.net/?p=657 Read more]]> An elegant way of how to do a sorted iteration over all entries of a JavaScript Map with a for-loop using a custom iterator implemented with Symbol.iterator, function* and yield*.

Your data in a Map

When you need to have a fast key-based access to your data, storing it in a JavaScript Map is perfect for that. So let’s assume you have the following data of different countries with their country-code as a key and the full name and the number of your active users in that country as the value or payload.

var countries = new Map();

countries.set('DE', { name: 'Germany', activeUsers: 15000 });
countries.set('PL', { name: 'Poland', activeUsers: 13900 });
countries.set('UK', { name: 'United Kingdom', activeUsers: 14500 });

Having the Map-object in place, it is very easy and efficient to acces every dataset by it’s key, e.g. countries.get('UK'). Also checking if a particular key exists with countries.has('DE') or getting all keys as an Array with countries.keys() is well supported by JavaScript’s Map.

But what if you also need to have the Map’s entries sorted by an attribute of their value-part and want to iterate over them with a for loop? E.g. listing all countries descending to their number of active users? Well, this is where things start to become a bit more tricky – so let’s see how to solve this.

Iterate the Map – standard (unsorted) way

Maps support a convenient way to iterate over all their entries with for...of and foreach. However, this iterations will always return the elements of the Map in the order they were inserted. For our example above this would look like this.

for (let [key, info] of countries) {
  console.log(key + ' - ' + info.name + ', ' + info.activeUsers);
}

// DE - Germany, 15000
// PL - Poland, 13900
// UK - United Kingdom, 14500

Straight-forward approach – creating a sorted Array out of the Map

The obvious approach to achieve an ordered iteration according to the number of active users in each country would be to create an array out of the Map’s entries using the spread operator (…). This can then be sorted using standard Array functionality.

let sortedCountries = [...countries.entries()].sort((a, b) => b[1].activeUsers - a[1].activeUsers);
for (let [key, info] of sortedCountries) {
    console.log(key + ' - ' + info.name + ', ' + info.activeUsers);
}

// DE - Germany, 15000
// UK - United Kingdom, 14500
// PL - Poland, 13900

However, this approach has a major drawback. The sortedCountries variable has to be refreshed every time an entry is added or deleted in the Map. This is because it is created from a shallow copy of the Map contents using the spread (…) operator and therefore represents only the state of the Map at a certain point in time (precisely: the state of the Map when sortedCountries is set).

let sortedCountries = [...countries.entries()].sort((a, b) => b[1].activeUsers - a[1].activeUsers);

// ...

countries.set('IT', { name: 'Italy', activeUsers: 14200 });

for (let [key, info] of sortedCountries) {
    console.log(key + ' - ' + info.name + ', ' + info.activeUsers);
}

// Italy missing in output...

Elegant approach – custom iterator with function* and yield*

To overcome the drawbacks of the naive approach, JavaScript provides the functionality of creating custom iterators with Symbol.iterator. To do so, we have to assign a generator function to this iterator symbol. A generator function is declared by function* and returns an iteratable generator object. Within this generator function, we’ll yield-return all entries of the Map sorted in the way we want it using the yield* operator.

Ahh… what he says? Let’s first see the code, then it becomes far more easy to understand how it works…

countries[Symbol.iterator] = function* () {
    yield* [...countries.entries()].sort((a, b) => b[1].activeUsers - a[1].activeUsers);
}

For better understanding let’s break down this two lines of code.

With countries[Symbol.iterator] we define a new custom iterator. Or in other words, we declare what will be used/iterated when the countries Map is put into a for…of loop.

This iterator is then assigned a so called generator function with the keyword function*. Such a function returns – or yields – an iteratable object that is used by the calling for loop.

With […countries.entries()].sort((a, b) => b[1].activeUsers - a[1].activeUsers) we create a new array containing all the entries of the countries Map and sort it descending to the number of active users each country has.

Instead of specifying a seperate yield for each element of the sorted array being returned from the function, we use the yield* expression. This simply automatically iterates over the array and returns (yields) every element in the iteratable result. So the above code is just a short form for…

countries[Symbol.iterator] = function* () {
    let sorted = [...countries.entries()].sort((a, b) => b[1].activeUsers - a[1].activeUsers);
    for([key, value] of sorted) yield [key, value];
}

Since an iterator is always automatically evaluated when it is used in a for…of loop, we only need to declare it once. All changes to the Map – adding or deleting entries – are automatically reflected by the iterator when looping over it again.

Sorted for-loop over a Map – putting it all together

Now let’s put the complete example together. Alle the magic happens at the end in two lines of code…

var countries = new Map();

countries.set('DE', { name: 'Germany', activeUsers: 15000 });
countries.set('PL', { name: 'Poland', activeUsers: 13900 });
countries.set('UK', { name: 'United Kingdom', activeUsers: 14500 });

// *** Here comes the magic :) ***
countries[Symbol.iterator] = function* () {
   yield* [...countries.entries()].sort((a, b) => b[1].activeUsers - a[1].activeUsers);
}

console.log('with custom iterator:');
for (let [key, info] of countries) {
    console.log(key + ' - ' + info.name + ', ' + info.activeUsers);
}

// DE - Germany, 15000
// UK - United Kingdom, 14500
// PL - Poland, 13900

// Adding an antry to the map and check if sorting is still valid
countries.set('IT', { name: 'Italy', activeUsers: 14200 });

console.log('after adding an entry:');
for (let [key, info] of countries) {
    console.log(key + ' - ' + info.name + ', ' + info.activeUsers);
}

// DE - Germany, 15000
// UK - United Kingdom, 14500
// IT - Italy, 14200
// PL - Poland, 13900

A public Gist is also available for that example.

Happy coding đŸ™‚

Useful links

]]>