ES2015 Maps

This article introduces the new ES2015 Map and WeakMap types, and compares their use to that of plain object literals.

Maps are data structures that pair keys with values, and are typically used in applications as indexes.

In PHP, maps can be created with associative arrays:

[
    '1' => 'ninjas',
    '2' => 'pirates',
    '3' => 'stormtrooper',
]

In JavaScript maps can be created with object literals:

{
  '1': 'ninjas',
  '2': 'pirates',
  '3': 'stormtrooper'
}

The utilitarian purpose for using maps is to quickly access some value by key without searching through an array for that value. Consider a JavaScript array of user objects. To find a user with an ID of 1003, some kind of iteration is required.

const users = [
    {id: 1001, name: 'Nicholas'},
    {id: 1002, name: 'Luiz'},
    {id: 1003, name: 'Megan'}
];
const findUser = (userID) => {
    const index = 0,
      maxIndex = users.length - 1;
    while (index <= maxIndex) {
      if (users[index].id === userID) {
        // found
        return users[index];
      }
      index += 1;
    }
};
console.log(findUser(1003));

Not only is the code cumbersome, but for large arrays, CPU cycles would be wasted each time the array was searched. To avoid these drawbacks, maps are often employed as indexes, whereby a value can be dereferenced by key.

const userMap = {
  '1001': {id: 1001, name: 'Nicholas'},
  '1002': {id: 1002, name: 'Luiz'},
  '1003': {id: 1003, name: 'Megan'}
};
const findUser = (userID) => {
    return userMap[userID];
};
console.log(findUser(1003));

JavaScript object literals work well as maps but have a few weaknesses.

First, all object literal properties must be strings (or Symbols in ES2015), so type coercion happens automatically when a variable (e.g., userID) is used as a dynamic key. For primitive types like numbers this works fine, as the string value is identical to the numeric value. But there are cases where using object — or even array—references as keys can be valuable, and simple string serialization cannot facilitate this.

Second, literals have no methods for performing often helpful map-related tasks, like:

  • fetching all keys: Object.keys(userMap)
  • fetching all values: _.values(userMap)
  • iterating over values: _.forOwn(userMap, (value, key) => {…})
  • deleting a key/value pair: delete userMap[1001]
  • clearing all keys/values: userMap = {}
  • testing for the existence of a key is noisy: userMap.hasOwnProperty(key).

Third, there is no way to determine the number of entries (key/value pairs) in an object literal. This is typically done by evaluating the number of keys: Object.keys(userMap).length.

Finally, object literals have prototypes which means they inherit keys from Object.prototype. Avoiding these inherited keys is typically only an issue when evaluating a literal in a for loop, however.

ES2015 introduces a new JavaScript constructor function called Map that creates an iterable object that can be used for all scenarios in which a literal is typically used as a map. Map objects are created by invoking the constructor function with the new keyword:

const userMap = new Map();

Instead of accessing object keys/values directly, a Map object exposes instance methods for managing all entries, shown below.

Map.prototype.clear()
Map.prototype.delete(key)
Map.prototype.entries()
Map.prototype.forEach(function)
Map.prototype.get(key)
Map.prototype.has(key)
Map.prototype.keys()
Map.prototype.set(key, value)
Map.prototype.values()

Keys in Map objects may be of any type, including objects, and are compared to one another using the JavaScript “same value” algorithm.

Because Map objects can use objects as keys, those keys will not be garbage collected when all other references to them are eliminated. So if you don’t hang on to all of your object keys outside of the map, those entries can never be removed (except by clearing the whole map).

To solve this problem, the WeakMap type was also introduced in ES2015. WeakMap objects behave similar to Map objects, except their keys will “expire” when no references remain to them, and will get picked up by the garbage collector. The API for WeakMap is smaller than Map (shown below) because the state of the garbage collector at any point may interfere with the operations that iterate over members (like keys() and values()).

WeakMap.prototype.clear()
WeakMap.prototype.delete(key)
WeakMap.prototype.get(key)
WeakMap.prototype.has(key)
WeakMap.prototype.set(key, value)

As an example I’ve created a simple UI component that consists of a list of upcoming classroom assignments that may be displayed or hidden when a chevron is clicked next to the list title. Clicking the chevron triggers the _onChevronClicked() handler in the code below.

const expansionMap = new Map();

const assignmentDetailsInterface = {
    _onChevronClicked: function (e) {
        e.preventDefault();
        const $chevron = $(e.target);
        const id = $chevron.attr('data-id');
        const isExpanded = !!expansionMap.get(id);
        $chevron.toggleClass('expanded', !isExpanded)
            .closest('.classroom__upcoming-assignment')
            .find('.classroom__upcoming-assignment__details')
            .toggleClass('expanded', !isExpanded);
        expansionMap.set(id, !isExpanded);
    }
};

When the _onChevronClicked() event handler fires, it queries the expansionMap object to determine if the ID for the element has a value associated with it (true or false). This value indicates whether the element is expanded or contracted. If the key is not present in the expansionMap, the get() method returns undefined, so by using the boolean coercion operator !!, a missing key will evaluate to false. Once the status of the element is determined, it is either expanded or contracted (whichever is the opposite of its current state), and the new value is added to the expansionMap for the element’s ID.

This is a very simple use case but illustrates how a map works, and why it might be useful for tracking data or state.