JavaScript maps and sets part 1: Maps

JavaScript maps and sets part 1: Maps

15 Dec 2020 maps javascript

When ECMAScript 6 landed in 2015 we gained not one but two new data collection structures - namely, maps and sets. We'll be looking at how they work and how they differ from traditional arrays and objects.

First up, maps!

What are maps? 🔗

Maps are like super objects. They store key/value pairings just like objects:

let map = new Map(); map.set('foo', 'bar'); map.get('foo'); //"bar"

Notice right off the bat how, to retrieve a value from a map, we use its API method get() rather than square bracket syntax. We'll see more of the API later.

However, compared to traditional objects they have several advantages. Let's look at some of them.

Keys of any data type 🔗

Firstly and most importantly, maps can have any data type as keys. This differs from objects, which support only strings and symbols as keys.

With an object, if you try to store anything other than a string or symbol as a key, it'll be cast to a string:

let obj = null, obj2 = null; obj[obj2] = 'foo'; obj[1] = 'bar'; Object.keys(obj); //["1", "[object Object]"]

Attempting to use obj2 as a key actually results in using its string equivalent ("[object Object]") as a key. Likewise, the string "1" is used rather than the integer 1.

Maps have no such limitation.

let map = new Map(), obj = null; map.set(obj, 'foo'); map.set(1, 'bar');

With a map, our object and integer really are used as keys - they're not implicitly cast to string counterparts first.

This can be really useful if you want to associate two complex types with one another. A map then becomes a sort of pivot table, with no need for a primitive, invented key to handle the association.

Suppose we have an object of books:

let books = [{ title: 'A Confederacy of Dunces', genre: 'comedy', author: 'John Kennedy Toole' }, { title: 'Murder on the Orient Express', genre: 'crime', author: 'Agatha Christie' }, { title: 'Theft by Finding', genre: 'biography', author: 'David Sedaris' }];

Suppose we then have a person class, and we want to associate the person with books of a genre they like.

class Person { constructor(name, faveGenre) { this.name = name; this.genre = faveGenre; } getGenre() { return this.genre; } } let people = [ new Person('James', 'comedy'), new Person('Cilla', 'crime') ]; peopleToBooks = new Map(); people.forEach(person => peopleToBooks.set( person, books.filter(book => book.genre == person.getGenre()) ));

For each person, we log their genre preferences, then we create a map associating the person (literally their object) to a derived array of books suiting them.

It's this complex-to-complex associative power that's so, well, powerful. Without a map we'd have to create and use another level of data to associate the two. For example, as an object, we'd need to invent a string key:

{ James: {person: /* the person object */, books: /* the books array */} }

The problem there is we can have only one person called James in our object. Not so for maps; since two objects are never identical, there's no clash if we use those as map keys, even if each object relates to a person with the same name.

Maps are iterable 🔗

Hazy on iterables/iterators? Check out my detailed guide.

Maps are iterable, unlike objects. With objects, you'd first need to derive an iterator via a method like Object.values(myObj) (which returns an array, i.e. an iterable).

This means we can feed it to some kind of iterator construct:

let map = new Map(); map.set('foo', 'bar'); for (let entry of map) console.log(entry); //"foo", "bar" console.log(...map); //spread syntax - same result

This invokes the default iterator, which for maps is its entries() method. That is, it's this method that a map's Symbol.iterator property points to. The following, then, are equivalent:

for (let entry of map) console.log(entry); //uses default iterator - entries() for (let entry of map.entries()) console.log(entry); //explicit use of entries() for (let entry of map[Symbol.iterator]()) console.log(entry); //alias of entries()

Reliable order 🔗

Most JavaScript developers learn early on that you should never rely on the order of items in traditional objects. Actually this is less of a concern these days, since objects are ordered these days, but the rule stuck around, and you won't find my experienced JS devs relying on order in objects.

No such problem with maps, where order is reliable, and based on the order you add items to it, however you iterate over it.

let mymap = new Map(); mymap.set('1 key', '1 val'); mymap.set('2 key', '2 val'); for (let x of mymap.values()) console.log(x); //"1 val", "2 val"

With objects, even though order is reliable these days, there's no single means of getting all of an object's properties in one go. As MDN notes:

for-in includes only enumerable string-keyed properties; Object.keys includes only own, enumerable, string-keyed properties; Object.getOwnPropertyNames includes own, string-keyed properties even if non-enumerable; Object.getOwnPropertySymbols does the same for just Symbol-keyed properties, etc.)

One of the reasons maps can honour item order is maps do not have inherited content. With objects, you have a mixture of "own" properties (hence methods like getOwnPropertyNames()) and inherited ones. With maps, what's in the map is only what you put in it.

API 🔗

Maps come with a small API that we can use to work with them. We've seen some of the methods already in the code snippets above.

Constructor 🔗

First up, there's the map constructor, new Map(). You can pass it an optional iterable of "entries" with which to prepopulate the map. This should be a multidimensional array of keys and values, like so:

let map = new Map([['foo', 'bar'], ['foo2', 'etc']]); map.get('foo'); //"bar"

.size 🔗

Maps also have a size property to return their length. Objects have no in-built mechanism to return this info; you'd have to first get an iterable, e.g. via Object.values(), and check the length property of the derived array.

let mymap = new Map(); mymap.set('foo', 'bar'); mymap.size; //1 - get size of map let myobj = {foo: 'bar'}; Object.values(myobj).length; //1 - get size of object

Map content methods 🔗

Next up there's a few methods used to handle the content of a map. These are:

  • get(key) - get a specific key's value from the map
  • set(key, val) - set a key/value in the map - returns the map, so you can chain multiple calls to set()
  • clear() - empty the map
  • delete(key) - delete a specific entry in the map, by key
  • has(key) - returns boolean denoting whether the map contains an entry denoted by the passed key

Note that has() looks up based on key; it will return true even if the value associated with the key is a falsy value.

One important point is that you should always use get() / set() when getting or setting values from/to the map. Using traditional, object-based square bracket syntax will not error (since maps are a type of object and we can use this syntax to set traditional properties on any object), but the property will not be got / set.

let mymap = new Map(); mymap.foo = 'bar'; //doesn't get added to map! mymap.get('foo'); //undefined

Iterator methods 🔗

Finally there's a few methods to derive iterators, namely values(), keys(), forEach() and entries(). Objects also have these, but with objects these methods exist as static methods of the global Object object, rather than inherited (i.e. prototypal) methods.

for (let x in mymap.values()) console.log(x); //["bar"] for (let x in Object.values(myobj)) console.log(x); //["bar"]

As we noted earlier, maps are themselves iterable, so we don't have to derive an iterator to iterate over them, unlike with objects. We can just feed the map itself straight to the iteration process, which then invokes the default iterator, .entries().

for (let entry in mymap) console.log(entry); //["foo", "bar"] for (let entry in myobj); //error - objects aren't ierable

Summary

Maps are like objects, but with some key improvements and differences. Most notably, they can have any data type as keys, rather than just strings and symbols.

Moreover, item order is reliable, however you iterate over the map. And speaking of which, maps are iterable.

Maps also have a small API for working with them. Of particular importance is get() / set(), which should always be used for retrieving/inserting entries from/into the map, rather than traditional square-bracket syntax.

Maps have a size property to get its length, unlike objects, for which no single mechanism exists to get their length (not least because objects contain a mixture of own and inherited properties). With maps, there's no inherited content, unlike objects, which have a mixture of "own" and inherited properties.

---

I hope you found this guide useful. Next up in part 2, Weak Maps, where things get a little more... exotic!

Did I help you? Feel free to be amazing and buy me a coffee on Ko-fi!