Search code examples
javascriptdictionaryiterator

JavaScript fails to iterate over Map keys or values


In certain scenarios an attempt to iterate over JavaScript Map keys or values fails. The script simply doesn't enter the loop.

function pushAndLog(array, values) {
    array.push(...values);
    for (const value of values) {
        console.log(value);
    }
}

const array = [];
const map = new Map([["a", 1], ["b", 2]]);
pushAndLog(array, map.keys());

In this example, array gets properly updated, but map keys don't get logged.


Solution

  • Reason

    This happens because Map.keys() and Map.values() methods return an Iterator instead of an Iterable object.

    How to fix

    Avoid reusing iterators

    As opposed to Iterable, Iterator is a stateful object. You can't reuse it. In particular, if you pass an Iterator to a function, you can't use it afterwards - you should create a new Iterator. It is not always possible though.

    Use iterables

    The easiest way to fix this code is to pass a collection to the function instead of an iterator:

    pushAndLog(array, [...map.keys()]);
    

    But this solution is generally not the most efficient, as creation of a new collection consumes memory and CPU time. There's a better solution. Add the following utilities to your code:

    export function getIterableKeys<K, V>(map: Iterable<readonly [K, V]>): Iterable<K> {
        return {
            [Symbol.iterator]: function* () {
                for (const [key, _] of map) {
                    yield key;
                }
            }
        };
    }
    
    export function getIterableValues<K, V>(map: Iterable<readonly [K, V]>): Iterable<V> {
        return {
            [Symbol.iterator]: function* () {
                for (const [_, value] of map) {
                    yield value;
                }
            }
        };
    }
    

    Use them instead of native Map.keys() and Map.values() methods:

    pushAndLog(array, getIterableKeys(map));
    

    It works better for bigger maps.

    Notes:

    • The functions are written in TypeScript. To translate them to JavaScript, simply delete type definitions from function headers.
    • The functions are written as an ES6 module. To translate them to plain JavaScript, simply delete export keywords.
    • The nested functions are generator functions, i.e. they return a Generator which can also serve as Iterator. In order to use this code, please make sure that your interpreter supports generators or use TypeScript/Babel transpilers in combination with regenerator-runtime library.

    Some thoughts

    Returning an Iterator object from Map.keys() and Map.values() methods is a bad design decision we have to live with and always keep in mind. Such errors are sometimes unexpected and very hard to track, as iterators serve well until you try to reuse them. So you have to stay vigilant.

    In other mature programming languages such methods smartly return iterable objects:

    Both Java Iterable and .NET IEnumerable are reusable, so their users can't run into the same issue. This approach is much safer. It is a question for me why a relatively young JavaScript community invents their own solutions instead of inheriting best practices from existing mature programming languages.

    Even though JavaScript Iterator works more or less like an Iterable object (you may use it in for..of loops, spread operators, collection constructors etc.), it formally breaks the Iterable class contract which is meant to be reusable, while Iterator is not.

    The problem is amplified by TypeScript definitions which pretend that Map.keys() and Map.values() return a so-called IterableIterator object extending Iterable interface, which is wrong for the same reasons.