Use Maps More and Objects Less

https://www.builder.io/blog/maps

Objects in JavaScript are awesome. They can do anything! Literally…anything.

But, like all things, just because you can do something, doesn’t (necessarily) mean you should.

// 🚩
const mapOfThings = {}

mapOfThings[myThing.id] = myThing

delete mapOfThings[myThing.id]

For instance, if you're using objects in JavaScript to store arbitrary key value pairs where you'll be adding and removing keys frequently, you should really consider using a map instead of a plain object.

// ✅
const mapOfThings = new Map()

mapOfThings.set(myThing.id, myThing)

mapOfThings.delete(myThing.id)

Performance issues with objects

Whereas with objects, where the delete operator is notorious for poor performance, maps are optimized for this exact use case and in some cases can be seriously faster.

Benchmark result (from the below link) showing Maps about 5x faster in this benchmark compared to Objects

Note of course this is just one example benchmark (run with Chrome v109 on a Core i7 MBP). You can also compare another benchmark created by Zhenghao He. Just keep in mind — micro benchmarks like this are notoriously imperfect so take them with a grain of salt.

That said, you don’t need to trust my or anyone else’s benchmarks, as MDN itself clarifies that maps are specifically optimized for this use case of frequently adding and removing keys, as compared with an object that is not as optimized for this use case:

Screenshot of the MDN docs pointing to Maps being mentioned to have better performance than Objects for scenarios that involve frequent additions and removals of key-value pairs

If you are curious why, it has to do with how JavaScript VMs optimize JS objects by assuming their shape, whereas a map is purpose-built for the use case of a hashmap where keys are dynamic and ever-changing.

Read more about how VMs assume shapes in this thread by Miško (CTO of Builder.io, and creator of Angular and Qwik):

Another great article is What’s up with monomorphism, which explains the performance characteristics of objects in JavaScript, and why they are not as optimized for hashmap-like use cases of frequently adding and removing keys.

Understanding monomorphism can improve your JavaScript performance 60x.

But beyond performance, maps also solve for several issues that exist with objects.

Built-in keys problem

One major issue of objects for hashmap-like use cases is that objects are polluted with tons of keys built into them already. WHAT?

const myMap = {}

myMap.valueOf // => [Function: valueOf]
myMap.toString // => [Function: toString]
myMap.hasOwnProperty // => [Function: hasOwnProperty]
myMap.isPrototypeOf // => [Function: isPrototypeOf]
myMap.propertyIsEnumerable // => [Function: propertyIsEnumerable]
myMap.toLocaleString // => [Function: toLocaleString]
myMap.constructor // => [Function: Object]

So if you try and access any of these properties, each of them has values already even though this object is supposed to be empty.

This alone should be a clear reason not to use an object for an arbitrary-keyed hashmap, as it can lead to some really hairy bugs you’ll only discover later.

Iteration awkwardness

Speaking of strange ways that JavaScript objects treat keys, iterating through objects is riddled with gotchas.

For instance, you may already know not to do this:

for (const key in myObject) {
  // 🚩 You may stumble upon some inherited keys you didn't mean to
}

And you may have been told instead to do this:

for (const key in myObject) {
  if (myObject.hasOwnProperty(key)) {
    // 🚩
  }
}

But this is still problematic, as myObject.hasOwnProperty can easily be overridden with any other value. Nothing is preventing anyone from doing myObject.hasOwnProperty = () => explode().

So instead you should really do this funky mess:

for (const key in myObject) {
  if (Object.prototype.hasOwnProperty.call(myObject, key) {
    // 😕
  }
}

Or if you prefer your code to not look like a mess, you can use the more recently added Object.hasOwn:

for (const key in myObject) {
  if (Object.hasOwn(myObject, key) {
    // 😐
  }
}

Or you can give up on a for loop entirely and just use Object.keys with forEach.

Object.keys(myObject).forEach((key) => {
  // 😬
})

However, with maps, there are no such issues at all. You can use a standard for loop, with a standard iterator, and a really nice destructuring pattern to get both the key and value at once:

for (const [key, value] of myMap) {
  // 😍
}

Me gusta.

In fact, this is so nice, we now have an Object.entries method to do similar with objects. It's one additional step so doesn't feel quite so first-class, but hey it works.

for (const [key, value] of Object.entries(myObject)) {
  // 🙂
}

Add that one to the long list of "loops in objects are ugly so choose one of the following 5 options you prefer".

But for Maps, it's nice to know there is one simple and elegant way to iterate built in directly.

Additionally, you can iterate over just keys or values as well:

for (const value of myMap.values()) {
  // 🙂
}

for (const key of myMap.keys()) {
  // 🙂
}

Key ordering

One additional perk of maps is they preserve the order of their keys. This has been a long asked for quality of objects, and now exists for maps.

This gives us another very cool feature, which is that we can destructure keys directly from a map, in their exact order:

const [[firstKey, firstValue]] = myMap

This can also open up some interesting use cases, like trivially implementing an O(1) LRU Cache:

One cool use case for Maps - creating a simple O(1) LRU cache

Given how Maps preserve the order of their keys, implementation is trivial:
https://t.co/HkMyzL03o0

Copying

Now you might say, oh, well, objects have some advantages, like they're very easy to copy, for instance, using an object spread or assign.

const copied = { ...myObject }
const copied = Object.assign({}, myObject)

But it turns out that maps are just as easy to copy:

const copied = new Map(myMap)

The reason this works is because the constructor of Map takes an iterable of [key, value] tuples. And conveniently, maps are iterable, producing tuples of their keys and values. Nice.

Similarly, you can also do deep copies of maps, just like you can with objects, using structuredClone:

const deepCopy = structuredClone(myMap)

Converting maps to objects and objects to maps

Converting maps to objects is readily done using Object.fromEntries:

const myObj = Object.fromEntries(myMap)

And going the other way is straightforward as well, using Object.entries:

const myMap = new Map(Object.entries(myObj))

Easy!

And, now that we know this, we no longer have to construct maps using tuples:

const myMap = new Map([
  ["key", "value"],
  ["keyTwo", "valueTwo"],
])

You can instead construct them like objects, which to me is a bit nicer on the eyes:

const myMap = new Map(
  Object.entries({
    key: "value",
    keyTwo: "valueTwo",
  })
)

Or you could make a handy little helper too:

const makeMap = (obj) => new Map(Object.entries(obj))

const myMap = makeMap({ key: "value" })

Or with TypeScript:

const makeMap = <V = unknown>(obj: Record<string, V>) =>
  new Map<string, V>(Object.entries(obj))

const myMap = makeMap({ key: "value" })
// => Map<string, string>

I’m a fan of that.

Key types

Maps are not just a more ergonomic and better-performing way to handle key value maps in JavaScript. They can even do things that you just cannot accomplish at all with plain objects.

For instance, maps are not limited to only having strings as keys — you can use any type of object as a key for a map. And I mean, like, anything.

myMap.set({}, value)
myMap.set([], value)
myMap.set(document.body, value)
myMap.set(function () {}, value)
myMap.set(myDog, value)

But, why?

One helpful use case for this is associating metadata with an object without having to modify that object directly.

const metadata = new Map()

metadata.set(myDomNode, {
  internalId: "...",
})

metadata.get(myDomNode)
// => { internalId: '...' }

This can be useful, for instance, when you want to associate temporary state to objects you read and write from a database. You can add as much temporary data associated directly with the object reference, without risk.

const metadata = new Map()

metadata.set(myTodo, {
  focused: true,
})

metadata.get(myTodo)
// => { focused: true }

Now when we save myTodo back to the database, only the values we want saved are there, and our temporary state (which is in a separate map) does not get included accidentally.

This does have one issue though.

Normally, the garbage collector would collect this object and remove it from memory. However, because our map is holding a reference, it'll never be garbage collected, causing a memory leak.

WeakMaps

Here’s where we can use the WeakMap type. Weak maps perfectly solve for the above memory leak as they hold a weak reference to the object.

So if all other references are removed, the object will automatically be garbage collected and removed from this weak map.

const metadata = new WeakMap()

// ✅ No memory leak, myTodo will be removed from the map
// automatically when there are no other references
metadata.set(myTodo, {
  focused: true,
})

Moar map stuff

A few remaining useful things to know about Maps before we continue on:

map.clear() // Clear a map entirely
map.size // Get the size of the map
map.keys() // Iterator of all map keys
map.values() // Iterator of all map values

Ok, you get it, maps have nice methods. Moving on.

Sets

If we are talking about maps, we should also mention their cousin, Sets, which give us a better-performing way to create a unique list of elements where we can easily add, remove, and look up if a set contains an item:

const set = new Set([1, 2, 3])

set.add(3)
set.delete(4)
set.has(5)

In some cases, sets can yield significantly better performance than the equivalent operations with an array.

Screenshot of the Array vs Set benchmark with Sets having almost 100x better performance

Blah blah microbenchmarks are not perfect, test your own code under real-world conditions to verify you get a benefit, or don't just take my word for it.

Similarly, we get a WeakSet class in JavaScript that will help us avoid memory leaks as well.

// No memory leaks here, captain 🫡
const checkedTodos = new WeakSet([todo1, todo2, todo3])

Serialization

Now you might say there's one last advantage that plain objects and arrays have over maps and sets — serialization.

Ha! You thought you got me on that one. But I’ve got answers for you, friend.

So, yes, JSON.stringify()/ JSON.parse() support for objects and maps is extremely handy.

But, have you ever noticed that when you want to pretty print JSON you always have to add a null as the second argument? Do you know what that parameter even does?

JSON.stringify(obj, null, 2)
//                  ^^^^ what dis do

As it turns out, that parameter can be very helpful to us. It is called a replacer and it allows us to define how any custom type should be serialized.

We can use this to easily convert maps and sets to objects and arrays for serialization:

JSON.stringify(obj, (key, value) => {
  // Convert maps to plain objects
  if (value instanceof Map) {
    return Object.fromEntries(value)
  }
  // Convert sets to arrays
  if (value instanceof Set) {
    return Array.from(value)
  }
  return value
})

Why did the JavaScript developer quit their job? They didn’t get arrays. Ha ha ho ho. Ok.

Now we can just abstract this into a basic reusable function, and serialize away.

const test = { set: new Set([1, 2, 3]), map: new Map([["key", "value"]]) }

JSON.stringify(test, replacer)
// => { set: [1, 2, 3], map: { key: value } }

For converting back, we can use the same trick with JSON.parse(), but doing the opposite, by using its reviver parameter, to convert arrays back to Sets and objects back to maps when parsing:

JSON.parse(string, (key, value) => {
  if (Array.isArray(value)) {
    return new Set(value)
  }
  if (value && typeof value === "object") {
    return new Map(Object.entries(value))
  }
  return value
})

Also note that both replacers and revivers work recursively, so they are able to serialize and deserialize maps and sets anywhere in our JSON tree.

But, there is just one small problem with our above serialization implementation.

We currently don’t differentiate a plain object or array versus a map or a set at parse time, so we cannot intermix plain objects and maps in our JSON or we will end up with this:

const obj = { hello: "world" }
const str = JSON.stringify(obj, replacer)
const parsed = JSON.parse(obj, reviver)
// Map<string, string>

We can solve this by creating a special property; for example, called __type, to denote when something should be a map or a set as opposed to a plain object or array, like so:

function replacer(key, value) {
  if (value instanceof Map) {
    return { __type: "Map", value: Object.fromEntries(value) }
  }
  if (value instanceof Set) {
    return { __type: "Set", value: Array.from(value) }
  }
  return value
}

function reviver(key, value) {
  if (value?.__type === "Set") {
    return new Set(value.value)
  }
  if (value?.__type === "Map") {
    return new Map(Object.entries(value.value))
  }
  return value
}

const obj = { set: new Set([1, 2]), map: new Map([["key", "value"]]) }
const str = JSON.stringify(obj, replacer)
const newObj = JSON.parse(str, reviver)
// { set: new Set([1, 2]), map: new Map([['key', 'value']]) }

Now we have full JSON serialization and deserialization support for sets and maps. Neat.

When you should use what

For structured objects that have a well-defined set of keys — such as if every event should have a title and a date — you generally want an object.

// For structured objects, use Object
const event = {
  title: "Builder.io Conf",
  date: new Date(),
}

They're very optimized for fast reads and writes when you have a fixed set of keys.

When you can have any number of keys, and you may need to add and remove keys frequently, consider using map for better performance and ergonomics.

// For dynamic hashmaps, use Map
const eventsMap = new Map()
eventsMap.set(event.id, event)
eventsMap.delete(event.id)

When creating an array where the order of elements matter and you may intentionally want duplicates in the array, then a plain array is generally a great idea.

// For ordered lists, or those that may need duplicate items, use Array
const myArray = [1, 2, 3, 2, 1]

But when you know you never want duplicates and the order of items doesn't matter, consider using a set.

// For unordered unique lists, use Set
const set = new Set([1, 2, 3])

About me

Hi! I'm Steve, CEO of Builder.io.

We make a way to drag + drop with your components to create pages and other CMS content on your site or app, visually.

You may find it interesting or useful: