Understanding JavaScript Maps

_staticvoid

Lucas Santos

Posted on January 13, 2024

Understanding JavaScript Maps

Since the dawn of humanity, we have tried to relate things in key and value objects, for example, a party guest list, class attendance lists, counting practically anything and so on.

La Pascaline, the world's first mechanical calculator

La Pascaline, the world's first mechanical calculator

When the first programmable machines appeared back in the day with mechanical calculators, a new problem was created: how can we transpose lists of keys and values into a computational structure that is both easily manipulated and secure?

And so Hash Maps came up.

And then the maps were made.

If you're used to working with Java, you've probably heard of or even used a hash map. They are the most common implementations of iterable objects containing keys and values or, as they're also called, dictionaries (C# people go nuts).

In strongly typed languages (such as Java and C#), we have to specify the types of both the key and the value we are going to store (see <int, string>), but how do we do this in Javascript? A language so dynamic that even programmers can't handle it?

Well, the answer to that is quite simple: we don't. As Javascript already has key and value objects naturally (much like Python and PHP), we don't need to do any special handling, so this creates an easily iterable structure, but what about security?

A very common use for Javascript maps is when we map string keys to an arbitrary value, as below:

let kennel = {}

function add (name, meta) {
  kennel[name] = meta
}

function get (name) {
  return kennel[name]
}

add('Heckle', { color: 'Black', breed: 'Dobermann' })
add('Jeckle', { color: 'White', breed: 'Pitbull'})
Enter fullscreen mode Exit fullscreen mode

We're going to create a list of dogs, obviously, but we have a bit of a problem with this project design:

  • Security: Imagine that one of the keys is called __proto__ or toString or anything inside Object.prototype then we're going to have a big problem, as I already said in my article about prototypes (in Portuguese), it's never a good idea to mess with them directly. Besides, we're creating practically unpredictable behavior in our code.
  • The iterations over these items will be quite verbose with Object.keys(kennel).forEach, unless you implement an iterator, which is also a little verbose.
  • The keys are limited to simple strings so it's complicated to create other keys of other types that aren't strings or are just references.

The first solution

To solve the first security problem, we simply add a prefix at the beginning of the key, which makes it different from anything native:

let kennel = {}
function add (name, meta) {
  kennel[`map:${name}`] = meta
}
function get (name) {
  return kennel[`map:${name}`]
}

add('Heckle', { color: 'Black', breed: 'Dobermann' })
add('Jeckle', { color: 'White', breed: 'Pitbull'})
Enter fullscreen mode Exit fullscreen mode

But luckily, ES6 came along to solve the problems we had in ES5.

Maps in ES6

With the advent of ES6 we have a specific structure for dealing with maps. It's called, amazingly, Map. Let's see how it would look if we converted our previous code to this new structure:

let map = new Map()
map.set('Heckle', { color: 'Black', breed: 'Dobermann' })
map.set('Jeckle', { color: 'White', breed: 'Pitbull' })
Enter fullscreen mode Exit fullscreen mode

Much simpler, right?

The main difference here is that we can use anything as a key. We are no longer limited to primitive types, but can also use functions, objects, dates, anything.

let map = new Map()
map.set(new Date(), function foo () {})
map.set(() => 'key', { foo: 'bar' })
map.set(Symbol('items'), [1,2])
Enter fullscreen mode Exit fullscreen mode

Obviously, many of these things don't make practical sense, but they are possible models to use.

The other significant change is that Map is iterable and produces a collection of values more or less of the type [ ['key', 'value'], ['key', 'value'] ] .

let map = new Map([
  [new Date(), function foo () {}],
  [() => 'key', { foo: 'bar' }],
  [Symbol('items'), [1,2]]
])
Enter fullscreen mode Exit fullscreen mode

That's another way to initialize a Map

The above is the same as using map.set for each value we insert. It's kind of dumb to add the items one by one when we can just include an entire iterable in our direct map . Which gives us the spread operator as a side effect:

let map = new Map()
map.set('f', 'o')
map.set('o', 'b')
map.set('a', 'r')

console.log([...map]) // [ ['f', 'o'], ['o', 'b'], ['a', 'r'] ]
Enter fullscreen mode Exit fullscreen mode

And since we have the power of the iterator in our hands, we can mix everything up: destructuring, for ... of , template literals and so on:

let map = new Map()
map.set('f', 'o')
map.set('o', 'b')
map.set('a', 'r')

for (let [key, value] of map) {
  console.log(`${key}: ${value}`)
  // 'f: o'
  // 'o: b'
  // 'a: r'
}
Enter fullscreen mode Exit fullscreen mode

And now that we know a bit about hash maps, we can talk about something that might not have been clear at first. Despite having an implementation of an addition API, all keys are unique, which means that if we keep writing to a key, it will only overwrite its own value:

let map = new Map()
map.set('a', 'a')
map.set('a', 'b')
map.set('a', 'c')
console.log([...map]) // [ ['a', 'c'] ]
Enter fullscreen mode Exit fullscreen mode

The case of NaN

This case is important to highlight. I think we all know that NaN is a very strange Javascript monster that behaves in some very... exotic ways...

"Naturally", an expression like NaN !== NaN will give true as a product, that is, NaN is not equal to NaN, and this usually causes this reaction in programmers:

But! In our Map, if we define a key as being NaN , its value will be NaN , that is, it will resolve itself:

console.log(NaN === NaN) // false
let map = new Map()
map.set(NaN, 'foo')
map.set(NaN, 'bar')
console.log([...map]) // [[NaN, 'bar']]
Enter fullscreen mode Exit fullscreen mode

And this is the presentation of a bizarre corner case that happens when we use Maps.

Maps and DOM

In ES5 we had a big problem when we needed to associate a DOM element with an API. The standard approach was to create extensive code like the one below, which simply returns an API object with various methods that manipulate DOM elements, allowing us to include or remove them from the Cache and get an API object for the element if it exists:

let 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) {
  let api = find(el)
  if (api) {
    return api
  }

  api = {
    method: method,
    method2: method2,
    method3: method3,
    destroy: destroy.bind(null, el)
  }
  put(el, api)
  return api
}
Enter fullscreen mode Exit fullscreen mode

In ES6, we have the great advantage of being able to index DOM elements as a key, so we can simply add the method relating to it in a value as a function.

let 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) {
  let api = find(el)
  if (api) {
    return api
  }

  api = {
    method: method,
    method2: method2,
    method3: method3,
    destroy: destroy.bind(null, el)
  }

  put(el, api)
  return api
}

Enter fullscreen mode Exit fullscreen mode

The gain here is not only in reading, but also in performance. Note that all the methods (or most of them) now only have one line of code, which means we can leave them inline without any problems, saving request space in a front-end application.

Other things with Maps

Symbols

Maps are collections of data, the famous collections that haunt every algorithm student in college. This means that it's easy to search within them whether a key exists or not. We have the exotic case of NaN that I mentioned above, but apart from that all Symbol objects are treated differently, so you'll need to use them by value:

let 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
Enter fullscreen mode Exit fullscreen mode

See what happened? Symbol is always a single object that returns a reference, so as long as you keep the reference that Symbol gave you, everything is fine.

let sym = Symbol()
let map = new Map([[NaN, 1], [sym, 2], ['foo', 'bar']])

console.log(map.has(sym)) // true
Enter fullscreen mode Exit fullscreen mode

Key-Cast

Unlike our initial model where we only had string keys, maps allow any type of information and do not perform any conversion of these types, we may be used to turning everything into a string, but remember that this is no longer necessary.

let map = new Map([[1, 'a']])

console.log(map.has(1)) //true
console.log(map.has('1')) //false
Enter fullscreen mode Exit fullscreen mode

Clear

We can clear a Map without losing the reference to it, because it is a collection, as already mentioned.

let map = new Map([[1, 2], [3, 4], [5, 6]])
map.clear()
console.log(map.has(1)) // false
console.log([...map]) // []
Enter fullscreen mode Exit fullscreen mode

Entries and Iterators

If you've read my article on iterators, you'll have recognized Map as a model for implementing this protocol.

So this means that you can iterate through its .entries(), but if we're using Map as an iterable, this will already be done anyway, so you don't need to iterate explicitly, see that map[Symbol.iterator] === map.entries will return true.

Just like .entries(), Map has two other methods that you can take advantage of, .keys() and .values(). So, basically they are self explanatory...

let map = new Map([[1, 2], [3, 4], [5, 6]])

console.log([...map.keys()]) // [1, 3, 5]
console.log([...map.values()]) // [2, 4, 6]
Enter fullscreen mode Exit fullscreen mode

Size

Map also comes standard with a read-only property called .size which gives you, at any time, the number of pairs in the hash map. It behaves basically the same as Array.prototype.length.

let 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
Enter fullscreen mode Exit fullscreen mode

Sorting

A second-to-last thing worth mentioning is that the pairs in a Map are iterated in insertion order and not in random order as was the case with Object.keys.

It's such that we used for ... in to iterate over an object's properties in a completely arbitrary way.

Loop

Finally, we have the well-known forEach() method, which basically does what the analogous Array method does.

Remember that we don't have keys as strings here.

let map = new Map([[NaN, 1], [Symbol(), 2], ['foo', 'bar']])
map.forEach((value, key) => console.log(key, value))
//NaN 1
//Symbol() 2
//'foo' 'bar'
Enter fullscreen mode Exit fullscreen mode

Conclusion

Maps have really come to the rescue when it comes to collections of keys and values or data dictionaries that we have to manipulate in a very complicated way in ES5, but we need to be careful and also, above all, get to know the structure we are working with so that we don't fall end up having a key object that we don't understand.

Be sure to follow more of my content on my blog!

I hope you enjoyed it, visit this article to find out more, I got a lot of content out of it and a lot of examples too :)

💖 💪 🙅 🚩
_staticvoid
Lucas Santos

Posted on January 13, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related