DEV Community

Cover image for Use Maps more and Objects less
Steve Sewell for Builder.io

Posted on • Edited on • Originally published at builder.io

Use Maps more and Objects less

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

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

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 being faster than 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 saying that Maps are more optimized for the use case of frequently adding and removing keys

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.

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

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

And you may have been told instead to do this:

for (const key in myObject) {
  if (myObject.hasOwnProperty(key)) {
    // 🚩
  }
}
Enter fullscreen mode Exit fullscreen mode

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) {
    // 😕
  }
}
Enter fullscreen mode Exit fullscreen mode

Or if you prefer your code to not look like a mess, you can give up on a for loop entirely and just use Object.keys with forEach.

Object.keys(myObject).forEach(key => {
  // 😬
})
Enter fullscreen mode Exit fullscreen mode

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) {
 // 😍
}
Enter fullscreen mode Exit fullscreen mode

Me gusta.

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

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

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

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

const copied = new Map(myMap)
Enter fullscreen mode Exit fullscreen mode

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

Converting maps to objects and objects to maps

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

const myObj = Object.fromEntries(myMap)
Enter fullscreen mode Exit fullscreen mode

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

const myMap = new Map(Object.entries(myObj))
Enter fullscreen mode Exit fullscreen mode

Easy!

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

const myMap = new Map([['key', 'value'], ['keyTwo', 'valueTwo']])
Enter fullscreen mode Exit fullscreen mode

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',
}))
Enter fullscreen mode Exit fullscreen mode

Or you could make a handy little helper too:

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

const myMap = makeMap({ key: 'value' })
Enter fullscreen mode Exit fullscreen mode

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

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

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: '...' }
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

Why did the JavaScript developer quit? 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 } }
Enter fullscreen mode Exit fullscreen mode

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

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

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']]) }
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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.

So this:

import { BuilderComponent, registerComponent } from '@builder.io/react'
import { Hero, Products } from './my-components'

// Dynamically render compositions of your components
export function MyPage({ json }) {
  return <BuilderComponent content={json} />
}

// Use your components in the drag and drop editor
registerComponent(Hero)
registerComponent(Products)
Enter fullscreen mode Exit fullscreen mode

Gives you this:

Gif of Builder.io

Top comments (5)

Collapse
 
etienneburdet profile image
Etienne Burdet

YES.

Weakmaps are my discovery of the year 22! 😛

Collapse
 
codeofrelevancy profile image
Code of Relevancy

Article is informative.. thank you for sharing

Collapse
 
fruntend profile image
fruntend

Сongratulations 🥳! Your article hit the top posts for the week - dev.to/fruntend/top-10-posts-for-f...
Keep it up 👍

Collapse
 
victornpb profile image
Info Comment hidden by post author - thread only accessible via permalink
Victor

While this is generally true, this article fails to explain WHY, and just tell you what to do, in a trust me bro way. So I would like to mention HiddenClasses is one of the reasons objects perform bad when mutating it’s shape.

Collapse
 
steve8708 profile image
Steve Sewell

It seems you misseed that I linked to 2 articles and one twitter thread that discuss this in more detail than I could ever fit into one article, and even mention shape assuming which is another common way of saying "hidden classes" ("hidden class", "hidden shape", "monomorphism" are all related concepts, 2/3 are directly mentioned here).

The point of this article was not to explain the technical underpinnings, that's what the links are for. This is about an overview of the benefits of maps, which touches on performance, ergonomics, and more.

Some comments have been hidden by the post's author - find out more