ponyfoo.com

ES6 Maps in Depth

Fix
A relevant ad will be displayed here soon. These ads help pay for my hosting.
Please consider disabling your ad blocker on Pony Foo. These ads help pay for my hosting.
You can support Pony Foo directly through Patreon or via PayPal.

Hello, this is ES6 – “Please make them stop” – in Depth. New here? Start with A Brief History of ES6 Tooling. Then, make your way through destructuring, template literals, arrow functions, the spread operator and rest parameters, improvements coming to object literals, the new classes sugar on top of prototypes, let, const, and the “Temporal Dead Zone”, iterators, generators, and Symbols. Today we’ll be discussing a new collection data structure objects coming in ES6 – I’m talking about Map.

Like I did in previous articles on the series, I would love to point out that you should probably set up Babel and follow along the examples with either a REPL or the babel-node CLI and a file. That’ll make it so much easier for you to internalize the concepts discussed in the series. If you aren’t the “install things on my computer” kind of human, you might prefer to hop on CodePen and then click on the gear icon for JavaScript – they have a Babel preprocessor which makes trying out ES6 a breeze. Another alternative that’s also quite useful is to use Babel’s online REPL – it’ll show you compiled ES5 code to the right of your ES6 code for quick comparison.

Before getting into it, let me shamelessly ask for your support if you’re enjoying my ES6 in Depth series. Your contributions will go towards helping me keep up with the schedule, server bills, keeping me fed, and maintaining Pony Foo as a veritable source of JavaScript goodies.

Thanks for reading that, and let’s go into collections now! For a bit of context, you may want to check out the article on iterators – which are closely related to ES6 collections – and the one on spread and rest parameters.

Now, let’s start with Map. I moved the rest of the ES6 collections to tomorrow’s publication in order to keep the series sane, as otherwise this would’ve been too long for a single article!

Before ES6, There Were Hash-Maps

A very common abuse case of JavaScript objects is hash-maps, where we map string keys to arbitrary values. For example, one might use an object to map npm package names to their metadata, like so:

var registry = {}
function add (name, meta) {
  registry[name] = meta
}
function get (name) {
  return registry[name]
}
add('contra', { description: 'Asynchronous flow control' })
add('dragula', { description: 'Drag and drop' })
add('woofmark', { description: 'Markdown and WYSIWYG editor' })

There’s several issues with this approach, to wit:

  • Security issues where user-provided keys like __proto__, toString, or anything in Object.prototype break expectations and make interaction with these kinds of hash-map data structures more cumbersome
  • Iteration over list items is verbose with Object.keys(registry).forEach or implementing the iterable protocol on the registry
  • Keys are limited to strings, making it hard to create hash-maps where you’d like to index values by DOM elements or other non-string references

The first problem could be fixed using a prefix, and being careful to always get or set values in the hash-map through methods. It would be even better to use ES6 proxies, but we won’t be covering those until tomorrow!

var registry = {}
function add (name, meta) {
  registry['map:' + name] = meta
}
function get (name) {
  return registry['map:' + name]
}
add('contra', { description: 'Asynchronous flow control' })
add('dragula', { description: 'Drag and drop' })
add('woofmark', { description: 'Markdown and WYSIWYG editor' })

Luckily for us, though, ES6 maps provide us with an even better solution to the key-naming security issue. At the same time they facilitate collection behaviors out the box that may also come in handy. Let’s plunge into their practical usage and inner workings.

ES6 Maps

Map is a key/value data structure in ES6. It provides a better data structure to be used for hash-maps. Here’s how what we had earlier looks like with ES6 maps.

var map = new Map()
map.set('contra', { description: 'Asynchronous flow control' })
map.set('dragula', { description: 'Drag and drop' })
map.set('woofmark', { description: 'Markdown and WYSIWYG editor' })

One of the important differences is also that you’re able to use anything for the keys. You’re not just limited to primitive values like symbols, numbers, or strings, but you can even use functions, objects and dates – too. Keys won’t be casted to strings like with regular objects, either.

var map = new Map()
map.set(new Date(), function today () {})
map.set(() => 'key', { pony: 'foo' })
map.set(Symbol('items'), [1, 2])

You can also provide Map objects with any object that follows the iterable protocol and produces a collection such as [['key', 'value'], ['key', 'value']].

var map = new Map([
  [new Date(), function today () {}],
  [() => 'key', { pony: 'foo' }],
  [Symbol('items'), [1, 2]]
])

The above would be effectively the same as the following. Note how we’re using destructuring in the parameters of items.forEach to effortlessly pull the key and value out of the two-dimensional item.

var items = [
  [new Date(), function today () {}],
  [() => 'key', { pony: 'foo' }],
  [Symbol('items'), [1, 2]]
]
var map = new Map()
items.forEach(([key, value]) => map.set(key, value))

Of course, it’s kind of silly to go through the trouble of adding items one by one when you can just feed an iterable to your Map. Speaking of iterables – Map adheres to the iterable protocol. It’s very easy to pull a key-value pair collection much like the ones you can feed to the Map constructor.

Naturally, we can use the spread operator to this effect.

var map = new Map()
map.set('p', 'o')
map.set('n', 'y')
map.set('f', 'o')
map.set('o', '!')
console.log([...map])
// <- [['p', 'o'], ['n', 'y'], ['f', 'o'], ['o', '!']]

You could also use a for..of loop, and we could combine that with destructuring to make it seriously terse. Also, remember template literals?

var map = new Map()
map.set('p', 'o')
map.set('n', 'y')
map.set('f', 'o')
map.set('o', '!')
for (let [key, value] of map) {
  console.log(`${key}: ${value}`)
  // <- 'p: o'
  // <- 'n: y'
  // <- 'f: o'
  // <- 'o: !'
}

Even though maps have a programmatic API to add items, keys are unique, just like with hash-maps. Setting a key over and over again will only overwrite its value.

var map = new Map()
map.set('a', 'a')
map.set('a', 'b')
map.set('a', 'c')
console.log([...map])
// <- [['a', 'c']]

In ES6 Map, NaN becomes a “corner-case” that gets treated as a value that’s equal to itself even though the following expression actually evaluates to trueNaN !== NaN.

console.log(NaN === NaN)
// <- false
var map = new Map()
map.set(NaN, 'foo')
map.set(NaN, 'bar')
console.log([...map])
// <- [[NaN, 'bar']]

Hash-Maps and the DOM

In ES5, whenever we had a DOM element we wanted to associate with an API object for some library, we had to follow a verbose and slow pattern like the one below. The following piece of code just returns an API object with a bunch of methods for a given DOM element, allowing us to put and remove DOM elements from the cache, and also allowing us to retrieve the API object for a DOM element – if one already exists.

var cache = []
function put (el, api) {
  cache.push({ el: el, api: api })
}
function find (el) {
  for (i = 0; i < cache.length; i++) {
    if (cache[i].el === el) {
      return cache[i].api
    }
  }
}
function destroy (el) {
  for (i = 0; i < cache.length; i++) {
    if (cache[i].el === el) {
      cache.splice(i, 1)
      return
    }
  }
}
function thing (el) {
  var api = find(el)
  if (api) {
    return api
  }
  api = {
    method: method,
    method2: method2,
    method3: method3,
    destroy: destroy.bind(null, el)
  }
  put(el, api)
  return api
}

One of the coolest aspects of Map, as I’ve previously mentioned, is the ability to index by DOM elements. The fact that Map also has collection manipulation abilities also greatly simplifies things.

var cache = new Map()
function put (el, api) {
  cache.set(el, api)
}
function find (el) {
  return cache.get(el)
}
function destroy (el) {
  cache.delete(el)
}
function thing (el) {
  var api = find(el)
  if (api) {
    return api
  }
  api = {
    method: method,
    method2: method2,
    method3: method3,
    destroy: destroy.bind(null, el)
  }
  put(el, api)
  return api
}

The fact that these methods have now become one liners means we can just inline them, as readability is no longer an issue. We just went from ~30 LOC to half that amount. Needless to say, at some point in the future this will also perform much faster than the haystack alternative.

var cache = new Map()
function thing (el) {
  var api = cache.get(el)
  if (api) {
    return api
  }
  api = {
    method: method,
    method2: method2,
    method3: method3,
    destroy: () => cache.delete(el)
  }
  cache.set(el, api)
  return api
}

The simplicity of Map is amazing. If you ask me, we desperately needed this feature in JavaScript. Being to index a collection by arbitrary objects is super important.

What else can we do with Map?

Collection Methods in Map

Maps make it very easy to probe the collection and figure out whether a key is defined in the Map. As we noted earlier, NaN equals NaN as far as Map is concerned. However, Symbol values are always different, so you’ll have to use them by value!

var map = new Map([[NaN, 1], [Symbol(), 2], ['foo', 'bar']])
console.log(map.has(NaN))
// <- true
console.log(map.has(Symbol()))
// <- false
console.log(map.has('foo'))
// <- true
console.log(map.has('bar'))
// <- false

As long as you keep a Symbol reference around, you’ll be okay. Keep your references close, and your Symbols closer?

var sym = Symbol()
var map = new Map([[NaN, 1], [sym, 2], ['foo', 'bar']])
console.log(map.has(sym))
// <- true

Also, remember the no key-casting thing? Beware! We are so used to objects casting keys to strings that this may bite you if you’re not careful.

var map = new Map([[1, 'a']])
console.log(map.has(1))
// <- true
console.log(map.has('1'))
// <- false

You can also clear a Map entirely of entries without losing a reference to it. This can be very handy sometimes.

var map = new Map([[1, 2], [3, 4], [5, 6]])
map.clear()
console.log(map.has(1))
// <- false
console.log([...map])
// <- []

When you use Map as an iterable, you are actually looping over its .entries(). That means that you don’t need to explicitly iterate over .entries(). It’ll be done on your behalf anyways. You do remember Symbol.iterator, right?

console.log(map[Symbol.iterator] === map.entries)
// <- true

Just like .entries(), Map has two other iterators you can leverage. These are .keys() and .values(). I’m sure you guessed what sequences of values they yield, but here’s a code snippet anyways.

var map = new Map([[1, 2], [3, 4], [5, 6]])
console.log([...map.keys()])
// <- [1, 3, 5]
console.log([...map.values()])
// <- [2, 4, 6]

Maps also come with a read-only .size property that behaves sort of like Array.prototype.length – at any point in time it gives you the current amount of entries in the map.

var map = new Map([[1, 2], [3, 4], [5, 6]])
console.log(map.size)
// <- 3
map.delete(3)
console.log(map.size)
// <- 2
map.clear()
console.log(map.size)
// <- 0

One more aspect of Map that’s worth mentioning is that their entries are always iterated in insertion order. This is in contrast with Object.keys loops which follow an arbitrary order.

The for..in statement iterates over the enumerable properties of an object, in arbitrary order.

Maps also have a .forEach method that’s identical in behavior to that in ES5 Array objects. Once again, keys do not get casted into strings here.

var map = new Map([[NaN, 1], [Symbol(), 2], ['foo', 'bar']])
map.forEach((value, key) => console.log(key, value))
// <- NaN 1
// <- Symbol() 2
// <- 'foo' 'bar'

Get up early tomorrow morning, we’ll be having WeakMap, Set, and WeakSet for breakfast :)

Liked the article? Subscribe below to get an email when new articles come out! Also, follow @ponyfoo on Twitter and @ponyfoo on Facebook.
One-click unsubscribe, anytime. Learn more.

Comments