DEV Community

Ben Mildren
Ben Mildren

Posted on • Edited on

Maps in ES6 - A Quick Guide

Overview

Maps and Sets often get lumped together in articles. They're both new ES6 collection types with similar interfaces but that's where the similarities stop. So it makes sense to have two separate articles on them.

Maps are like the love child of Arrays and Objects with a nice and sane interface.

You can instantiate one like so:

const foo = new Map()

In my last article on Symbols we looked at how Objects could use either Strings or Symbols as keys. Well Maps can use any type of data as a key. Use the .set() method to add key value pairs.

Map.prototype.set(key, value) : this

// as `this` is returned, we can chain the set methods
foo.set(1, 'one')
   .set(null, 'nothing')

const bar = { name: 'Ben' }
foo.set(bar, { age: 25 })

You can also pass the constructor a 2D iterable when creating a Map.

const foo = new Map([
  [undefined, 'hello'],
  [null, 'nada']
])

To retrieve items from a Map use the .get() method.

Map.prototype.get(key) : any

foo.get(undefined) // 'hello'

Unlike Objects (but more like Arrays), Maps have a handy .size property.

const foo = new Map([
  [1, 1],
  [2, 2]
])

Map.prototype.size : number

foo.size // 2

Maps check references to Objects for equality, so using Object literals is a bad idea as you won't be able to retrieve the values.

const foo = new Map()

foo.set({}, `you'll never catch me`)

foo.get({}) // undefined

There are a number of other useful methods Maps supply.

const zoe = { name: 'Zoe' }
const foo = new Map([
  ['hey', 0],
  [9, 'nine'],
  [zoe, { age: 23 }]
])

// .has checks if the collection contains a key
Map.proptype.has(key) : boolean
foo.has(9) // true
foo.has(5) // false


// .delete simply deletes an item
Map.prototype.delete(key) : boolean
foo.size // 3
foo.delete(5) // false
foo.size // 3
foo.delete(9) // true
foo.size // 2


// .clear deletes all values
Map.prototype.clear() : undefined
foo.clear() // undefined
foo.size // 0

Map API Summary

const foo = new Map()

foo.set(key, value) 

foo.get(key)

foo.has(key)

foo.delete(key)

foo.clear()

foo.size

Usage

One fantastic advantage Maps have over Objects is how you can iterate over them. They're built to be iterated over with baked in methods like .forEach and an iterator protocol for use with for..of loops.

They also preserve their order, unlike Objects and more like Arrays. So you can be sure everything is in the correct order.

const foo = new Map([
  [1, 'first'],
  [2, 'second'],
  [3, 'third']
])

Map.prototype.forEach(callback(value, key, map), [thisArg]) : undefined

foo.forEach((val, key) => console.log(val, key)) 
// first 1
// second 2
// third 3

The for..of loop returns each item (including both key and value), not just each value like you might expect.

You can use destructuring to separate the values.

for(let [key, val] of foo) {
  console.log(key, val)
}
// 1 'first'
// 2 'second'
// 3 'third'

Just like with Objects we get access to .keys(), .values() and .entries().

Map.prototype.keys() : Map iterator
const keys = foo.keys() // 1, 2, 3

Map.prototype.values() : Map iterator
const values = foo.values() // 'first', 'second', 'third'

Map.prototype.entries() : Map iterator
const entries = foo.entries() // [1, 'first'], [2, 'second'], [3, 'third']

These methods all return iterator objects. As they all conform to the iterator protocol, they can be used with for..of, like a generator or using the ... spread operator.

const keys = foo.keys()
for(let key of keys) {
  console.log(key)
}
// 1
// 2
// 3

const entries = foo.entries()
entries.next() // { value: [1, 'first'], done: false }
entries.next() // { value: [2, 'second'], done: false }
entries.next() // { value: [3, 'third'], done: false }
entries.next() // { value: undefined, done: true }

const values = foo.values()
console.log(...values) // 'first', 'second', 'third'

All of the above iteration methods make for a much more well rounded approach to iterating over a key value store, with a wealth of methods and consistent order; considerably better than on an Object.


A great use case for Maps is the potential to add metadata to existing objects or attaching some extra data to immutable objects. Symbols are also very good for this, see my previous article.

const dataObjects = [
  { name: 'Ben', age: 25 },
  { name: 'Zoe', age: 24 },
  { name: 'Roman', age: 50 },
]

const mapObjects = new Map()
dataObjects.forEach(val => mapObjects.set(val, { created: new Date() }))

console.log(mapObjects.get(dataObjects[0])) // [{ name: 'Ben', age: 25 }, { created: Sun Dec 17 2017 11:57:52 GMT+0000 (GMT) }]

Should I use a Map or an Object?

After seeing all that Maps can do, you might be thinking: "Will I ever need to use plain old Objects again?"

Let's look at the pros and cons of each:

Pros

  • Ordered. They stay in order like Arrays and have many iteration methods.
  • Objects (or any other type) as keys. Having objects as keys can be super powerful.

Cons

  • Loses a lot of Object methods. Most are very rarely used, but some are handy.
  • No fast dot notation, which means no destructuring.

So, if you need to iterate over your object or you want keys that aren't strings, your only option is Maps. If you know the shape of your object and expect it to be static (not adding or removing properties) then an Object might be best.

Details & Caveats

What is equal?

Maps use something called SameValueZero to determine if a Map has a key.

SameValueZero is very similar to === but has 2 notable differences:

  • NaN is equal to NaN
  • +0 is equal to -0
NaN === NaN // false

const foo = new Map()

foo.set(NaN, 'hello')
foo.get(NaN) // 'hello'

Non-existant keys

When looking up a key that doesn't exist, undefined is returned. You'll need to be aware of this if you might be expecting a return value of undefined.

new Map().get('notAKey') // undefined

Building from Objects

Object has a handy .entries() method which returns the 2D array structure needed for a Map.

const foo = { name: 'Ben', age: 25 }
const entries = Object.entries(foo) // [ ['name', 'Ben'], ['age', 25] ]
const foo = new Map(entries)

Weakmaps

Despite being in the Details & Caveats section, WeakMaps are not trivial at all! (I just figured this would be the best place to put them)

WeakMaps are a seperate data type to Maps. Just like their non-weak counterparts except they allow Javascript to remove their keys from memory if required and the keys must be objects.

They have a very similar api to Maps, with a few methods removed. These are all the methods WeakMaps have:

WeakMap.prototype.get(key) : this
WeakMap.prototype.set(key, value) : this
WeakMap.prototype.has(key) : boolean
WeakMap.prototype.delete(key, value) : boolean

Put simply, WeakMaps are basically Maps which allow its own object keys to be garbage collected. This helps with memory leaks.

If a Map has an object as a key and that object gets destroyed, the Map still retains that object as a key and it will stay in memory and won't be garbage collected as it's still "reachable" (don't worry, I'm going to write an article about how garbage collection works soon!).

const foo = new Map()

let bar = { name: 'Ben' }

foo.set(bar, { age: 25 })

foo.get(bar) // { age: 25 }

bar = null

foo.entries() // [ [{ name: 'Ben' }, { age: 25 }] ]

Conversely, if a WeakMap has a deleted object as a key, the WeakMap allows the garbage collector to remove that key and it's associated value.

const foo = new WeakMap()

// You can only use objects as keys, no primitives
foo.set('primitive', 1) // TypeError: Invalid value used as weak map key

let bar = { name: 'Ben' }

foo.set(bar, { age: 25 })

foo.get(bar) // { age: 25 }

bar = null

// If there are no other reference to bar, it is removed as a key from foo

Now, you might be wondering why I didn't show that the WeakMap doesn't retain the bar key in the above example, and that's because I can't!

WeakMaps do not have the tools to check, such as .size, .entries(), .keys() or .values(). And there is a good reason for this limitation: it wouldn't be safe to show it.

Javascript garbage collects at different times depending on what is executing, how intense current operations are, how much their is to collect, etc. Each Javascript engine handles these things slightly different too. So, although we know the WeakMaps key will be removed by the garbage collector, we do not know exactly when that will run. So it's not safe to use things like .size which might tell us we have 1 item one moment and 0 the next due to garbage collection running in the background.

Where to use WeakMaps over Maps

WeakMaps won't see a ton of usage but there will be niche cases where they come in handy.

One potential use case would be in state management where you need an object as a key. Most larger apps have a dedicated library for state management like Redux or Vuex. But maybe for a smaller app, if you were to roll your own state management system it might be handy to use WeakMaps there. Storing objects as keys, but also allowing those object to be garbage collected should they not be required anymore could be quite powerful.


There is also the possibility of using WeakMaps for private data.

const keepOut = new WeakMap()

class Person {
    constructor(name) {
    keepOut.set(this, { name })
  }
  get name () {
    return keepOut.get(this)
  }
}

const me = new Person('Ben')

me.name // { name: 'Ben' }

As the instance of Person is stored as the key in the WeakMap, the data cannot be accessed without having that instance of Person.


I mentioned above about using Maps to add metadata to objects or simply extend objects that couldn't be extended. WeakMaps are very handy for doing this with DOM elements.

const myH1 = document.querySelector('h1')
const myImg = document.querySelector('img')

const domElems = new Map([
  [myH1, { created: new Date() }],
  [myImg, { someExtraData: 'foo' }]
])

Now you can track extra data along with the DOM elements and the WeakMap will allow them to be garbage collected when the DOM elements are no longer needed!

Top comments (4)

Collapse
 
joegaudet profile image
Joe Gaudet

Minor gripe, but why oh why is the interface of for each (value, key) and not (key,value) seems backwards.

Also that #entries doesn't expose normal higher order functions is a bit lame.

Collapse
 
mildrenben profile image
Ben Mildren

I agree, would've been nice with key, value. But forEach on arrays is val, key - so I can see why they did it that way.

I don't fully understand what you mean about entries though?

Collapse
 
joegaudet profile image
Joe Gaudet
const map = new Map();
map.set('foo', 'bar');

// none of these are defined as entries is an
// iterator not a list
map.entries.map
map.entries.forEach
map.entries.filter
Collapse
 
joegaudet profile image
Joe Gaudet

See it's weird that arrays are forEach((val, index)), and Object.entries(object).forEach(([key, value])... Just seems inconsistent.