The JavaScript Map Data Structure

By

A complete guide to JavaScript Map: choosing it over Object, using any value as a key, iterating in order, converting data, and using WeakMap.

~~~

What is a Map

A Map data structure allows to associate data to a key.

Each key appears once and points to one value. Unlike a plain object, a Map accepts keys of any JavaScript type and gives us a focused API for adding, reading, deleting, and iterating entries.

Before ES6

ECMAScript 6 (also called ES2015) introduced the Map data structure to the JavaScript world, along with Set

Before its introduction, people generally used objects as maps, by associating some object or value to a specific key value:

const car = {}
car['color'] = 'red'
car.owner = 'Flavio'
console.log(car['color']) //red
console.log(car.color) //red
console.log(car.owner) //Flavio
console.log(car['owner']) //Flavio

Enter Map

ES6 introduced the Map data structure, providing us a proper tool to handle this kind of data organization.

A Map is initialized by calling:

const m = new Map()

Add items to a Map

You can add items to the map by using the set method:

m.set('color', 'red')
m.set('age', 2)

Get an item from a map by key

And you can get items out of a map by using get:

const color = m.get('color')
const age = m.get('age')

Delete an item from a map by key

Use the delete() method:

m.delete('color')

Delete all items from a map

Use the clear() method:

m.clear()

Check if a map contains an item by key

Use the has() method:

const hasColor = m.has('color')

Find the number of items in a map

Use the size property:

const size = m.size

Initialize a map with values

You can initialize a map with a set of values:

const m = new Map([['color', 'red'], ['owner', 'Flavio'], ['age', 2]])

Map keys

Just like any value (object, array, string, number) can be used as the value of the key-value entry of a map item, any value can be used as the key, even objects.

If you try to get a non-existing key using get() out of a map, it will return undefined.

How Map compares keys

const m = new Map()
m.set(NaN, 'test')
m.get(NaN) //test
const m = new Map()
m.set(+0, 'test')
m.get(-0) //test

Iterating over a map

Iterate over map keys

Map offers the keys() method we can use to iterate on all the keys:

for (const k of m.keys()) {
  console.log(k)
}

Iterate over map values

The Map object offers the values() method we can use to iterate on all the values:

for (const v of m.values()) {
  console.log(v)
}

Iterate over map key, value pairs

The Map object offers the entries() method we can use to iterate on all the values:

for (const [k, v] of m.entries()) {
  console.log(k, v)
}

which can be simplified to

for (const [k, v] of m) {
  console.log(k, v)
}

Convert to array

Convert the map keys into an array

const a = [...m.keys()]

Convert the map values into an array

const a = [...m.values()]

Map or Object?

Both can associate keys with values, but they are not interchangeable.

Use a Map when:

Use an object when the keys describe a fixed shape:

const user = {
  name: 'Flavio',
  role: 'author',
}

This object represents one user. A map is a better fit for a changing collection of users:

const users = new Map()

users.set(42, { name: 'Flavio' })
users.set(87, { name: 'Ada' })

Objects also inherit from a prototype unless you deliberately create one without it. That means names such as toString already have meaning. Map does not have that ambiguity because entries live separately from its methods.

Keys use identity

Primitive keys match by value:

const settings = new Map()
settings.set('theme', 'dark')

settings.get('theme') //'dark'

Object keys match by identity. Two objects with the same properties are still different keys:

const visits = new Map()
const page = { slug: 'javascript' }

visits.set(page, 3)

visits.get(page) //3
visits.get({ slug: 'javascript' }) //undefined

Keep the original key reference when you plan to retrieve the value later. If your data already has a stable string or numeric identifier, that identifier is often a simpler key.

Map key equality follows the SameValueZero algorithm. That is why NaN can retrieve a NaN key and why positive and negative zero refer to the same entry.

Setting a key twice replaces its value

Calling set() with an existing key updates the value. It does not add a duplicate:

const stock = new Map()

stock.set('notebook', 3)
stock.set('notebook', 5)

stock.size //1
stock.get('notebook') //5

set() returns the map, so calls can be chained:

const colors = new Map()
  .set('error', 'red')
  .set('success', 'green')

I usually prefer the array constructor when all initial entries are known. It is easier to scan and creates smaller diffs.

Iteration keeps insertion order

A map iterates in the order keys were first inserted. Updating an existing value does not move its key. Deleting a key and adding it again places it at the end.

The default iterator returns [key, value] pairs, which is why destructuring works:

for (const [name, quantity] of stock) {
  console.log(name, quantity)
}

forEach() is also available, but notice its argument order: value first, key second.

stock.forEach((quantity, name) => {
  console.log(name, quantity)
})

I prefer for...of when the loop needs break, continue, or await. forEach() cannot provide those control-flow behaviors.

Convert between maps, arrays, and objects

Spread a map to get an array of entries:

const entries = [...stock]
//[['notebook', 5]]

Build a map from transformed entries:

const prices = new Map([
  ['notebook', 8],
  ['pen', 2],
])

const pricesWithTax = new Map(
  [...prices].map(([name, price]) => [name, price * 1.2])
)

When every key is a string or symbol, Object.fromEntries() converts a map to an object:

Object.fromEntries(prices)
//{ notebook: 8, pen: 2 }

Be careful with non-string keys. Object property keys are strings or symbols, so converting a map can change or collapse keys.

Map and JSON

JSON.stringify() does not serialize map entries:

JSON.stringify(new Map([['color', 'orange']])) //'{}'

Convert it explicitly based on the shape you want:

JSON.stringify([...prices])
//'[["notebook",8],["pen",2]]'

The entry-array form preserves key types that JSON itself supports. The object form is friendlier to many APIs but only makes sense for string keys.

A practical counting pattern

A map is perfect for a frequency table:

const words = ['map', 'set', 'map', 'array', 'map']
const counts = new Map()

for (const word of words) {
  counts.set(word, (counts.get(word) ?? 0) + 1)
}

counts.get('map') //3

The nullish coalescing operator matters here. A missing key returns undefined, while a stored value could legitimately be 0, false, or an empty string.

WeakMap is for metadata tied to object lifetime

A WeakMap does not keep its object keys alive. This makes it useful for attaching metadata to objects without controlling how long those objects remain in memory:

const metadata = new WeakMap()

function track(element) {
  metadata.set(element, { clicks: 0 })
}

When nothing else references an element, the engine may collect it together with its metadata. We cannot observe when that happens, which is exactly why a WeakMap has no iterator, size, or clear().

Do not use WeakMap as a general-purpose cache with string keys. Use it when the key is an object and the value should live no longer than that object.

The exact behavior of Map and WeakMap is defined in the ECMAScript keyed collections specification. For a collection of unique values rather than key-value pairs, read my JavaScript Set guide.

Merge maps

Because a map constructor accepts entries, spreading provides a concise merge:

const defaults = new Map([
  ['theme', 'light'],
  ['fontSize', 16],
])

const saved = new Map([
  ['theme', 'dark'],
])

const settings = new Map([...defaults, ...saved])

Later entries replace earlier entries with the same key, so the saved theme wins. This is a shallow merge. If both maps store nested objects, those objects are still shared references.

Sort a map by sorting its entries

A map preserves insertion order but does not sort itself. Convert it to entries, sort them, and create a new map:

const scores = new Map([
  ['Ada', 92],
  ['Linus', 85],
  ['Grace', 98],
])

const ranked = new Map(
  [...scores].sort((a, b) => b[1] - a[1])
)

The original map remains unchanged. Use a locale-aware collator when sorting human names rather than comparing strings with < and >.

Use nested maps for two-dimensional keys

Concatenating two identifiers into one string can create collisions and escaping problems. A nested map keeps each key separate:

const permissions = new Map()

function setPermission(userId, projectId, role) {
  if (!permissions.has(userId)) {
    permissions.set(userId, new Map())
  }

  permissions.get(userId).set(projectId, role)
}

setPermission(42, 7, 'editor')
permissions.get(42).get(7) //'editor'

If the combination has a natural object identity, an object key can work too. The important part is choosing a key you can retrieve later.

Common Map mistakes

Do not use bracket notation to add entries:

const map = new Map()
map['color'] = 'orange'

map.has('color') //false
map.size //0

That code creates a normal property on the Map object. It does not create a map entry. Always use set() and get().

Do not test get() alone when undefined is a valid stored value:

const map = new Map([['result', undefined]])

map.get('result') //undefined
map.has('result') //true

Use has() to distinguish a missing key from a key whose value is undefined.

Finally, remember that copying a map is shallow:

const original = new Map([
  ['user', { name: 'Flavio' }],
])

const copy = new Map(original)
copy.get('user').name = 'Ada'

original.get('user').name //'Ada'

The map structure is new, but its object keys and values are the same references. If you need an independent data graph, decide how each value should be cloned rather than assuming the map constructor does it.

Tagged: JavaScript ยท All topics
~~~

Related posts about js: