For years, JavaScript code used plain objects as dictionaries:
const counts = {}; counts[word] = (counts[word] || 0) + 1. It mostly works.
But objects were designed to describe things with known properties, not to
hold arbitrary keys, and the gaps show.
Map and Set (ES2015) are collections built for the job. Their weak
siblings, WeakMap and WeakSet, solve a narrower problem: attaching data
to objects without keeping those objects alive forever.
Object keys are always strings (or symbols). Both objects convert to the same string, "[object Object]", so the second assignment overwrote the first.
Problems with objects as maps
Keys become strings. obj[1] and obj['1'] are the same property, and
an object used as a key turns into "[object Object]" — so different objects
collide.
Inherited keys. A plain object inherits from Object.prototype, so
'toString' in {} is true. Code that checks if (cache[key]) can be fooled
by keys like "constructor". (Object.create(null) avoids this, at the cost
of being unusual.)
Key order rules. Integer-like keys are listed first, in ascending order,
before other keys in insertion order: Object.keys({ 2: 'x', 1: 'y', b: 'z' })
is ['1', '2', 'b'].
Size and iteration take extra work: Object.keys(obj).length,
Object.entries(obj).
Map
const visits = new Map()
visits.set('/home', 3)
visits.set('/blog', 1)
visits.get('/home') // 3
visits.has('/about') // false
visits.size // 2
visits.delete('/blog')
for (const [path, count] of visits) console.log(path, count)
- Any value can be a key — objects, functions, numbers — compared by
identity (
SameValueZero: like===, exceptNaNmatchesNaN). - Insertion order, always, for every kind of key.
sizeis a property, updated as you go.- Frequent adds and deletes are what engines optimise Map for.
Using objects as keys is the big unlock: metadata for DOM nodes, memoisation keyed by an argument object, a graph's adjacency list keyed by node objects. But it comes with a catch, covered below: a Map keeps every key alive for as long as the Map exists.
Set
A Set holds each value at most once, in insertion order:
const tags = new Set(['js', 'ts', 'js'])
tags.size // 2
tags.has('ts') // true
tags.add('css')
const unique = [...new Set(array)] // de-duplicate an array
Membership checks are O(1) on average, compared with O(n) for
array.includes. If you're calling includes inside a loop over another
array, that's O(n × m) — converting one side to a Set first makes it
O(n + m).
Set operations
Sets now have the operations you'd expect, in current browsers and Node 22+:
const a = new Set([1, 2, 3]), b = new Set([2, 3, 4])
a.union(b) // Set {1, 2, 3, 4}
a.intersection(b) // Set {2, 3}
a.difference(b) // Set {1}
a.symmetricDifference(b) // Set {1, 4}
a.isSubsetOf(new Set([1, 2, 3, 4])) // true
They return new sets and leave the originals unchanged.
Grouping
Object.groupBy and Map.groupBy (ES2024) build the "group items by key"
structure in one call:
Object.groupBy([1, 2, 3, 4], (n) => (n % 2 ? 'odd' : 'even'))
// { odd: [1, 3], even: [2, 4] }
Map.groupBy(orders, (o) => o.customer) // Map keyed by customer object
Use Map.groupBy when the keys aren't strings.
JSON
JSON.stringify(new Map([['a', 1]])) gives "{}" — Maps and Sets don't
serialise. Convert explicitly:
JSON.stringify(Object.fromEntries(map)) // '{"a":1}' (string keys only)
JSON.stringify([...map]) // '[["a",1]]' (any keys)
JSON.stringify([...set]) // arrays for sets
new Map(Object.entries(JSON.parse(text))) // and back
Strong references: why a plain Map can leak
Attach some metadata to a DOM node, or cache a computed value keyed by an
object, and a Map seems like the obvious tool. It has a hidden cost: a
Map holds a strong reference to every key, so none of its keys can be
garbage collected while the map exists — even after every other part of the
program has stopped using them.
const cache = new Map();
function attachMetadata(el) {
cache.set(el, { clickCount: 0 });
}
let button = document.createElement("button");
attachMetadata(button);
button = null; // the only other reference to this element is gone...
// ...but the Map still holds it. The DOM node and its metadata can
// never be garbage collected while `cache` exists.
If the keys' lifetime is tied to something transient — DOM nodes that get removed, request-scoped objects, component instances that unmount — the map grows forever unless you remember to delete every entry by hand.
WeakMap: keys the garbage collector can still reclaim
WeakMap holds its keys weakly: being a key in a WeakMap does not
stop an object being garbage collected. Once nothing else references the
key, the engine can reclaim it, and the entry disappears with it — no
cleanup code required.
const cache = new WeakMap();
function attachMetadata(el) {
cache.set(el, { clickCount: 0 });
}
let button = document.createElement("button");
attachMetadata(button);
button = null; // nothing else references the element, so it and its
// WeakMap entry can be collected. No leak.
Same get / set / has / delete API as Map, with constraints that
follow from the weak-reference guarantee:
- Keys must be objects (or non-registered
Symbols) — primitives like strings and numbers aren't tracked by identity, so they can't be weakly referenced. - Not iterable — no
keys(),values(),entries(), nosize. If the collection could be enumerated, iterating it would itself keep every key alive. It's a deliberate restriction, not a missing feature.
WeakSet
WeakSet is to Set what WeakMap is to Map: weakly held membership for
"have I already processed this object?" checks that shouldn't keep the
object alive.
const processed = new WeakSet();
function process(obj) {
if (processed.has(obj)) return; // already handled
processed.add(obj);
// ... do work ...
}
Once obj is unreachable elsewhere, it's collected and silently drops out
of processed. There's no removal to forget.
Two WeakMap patterns
Object-keyed memoisation. A cache keyed by argument identity should never be the reason the argument outlives its natural lifetime:
const memoCache = new WeakMap();
function expensiveComputation(config) {
if (memoCache.has(config)) return memoCache.get(config);
const result = compute(config);
memoCache.set(config, result);
return result;
}
With a Map, every config object ever passed in would live as long as the
process. With a WeakMap, each cache entry dies with its argument.
Private state, before #fields. External code has no handle to the
map, so there's no way to read the data:
const _private = new WeakMap();
class Counter {
constructor() { _private.set(this, { count: 0 }); }
increment() { _private.get(this).count++; }
get value() { return _private.get(this).count; }
}
New code should prefer #count class fields,
which give the same privacy as built-in syntax. But the pattern shows why
WeakMap fits: an instance's private state shouldn't outlive the instance.
Choosing a collection
| You need | Use |
|---|---|
| A record with known fields, or JSON in and out | Plain object |
| A dictionary with dynamic or non-string keys, frequent changes, iteration | Map |
| "Have I seen this value?" — de-duplication, fast membership | Set |
| Metadata or a cache keyed by objects with their own lifetime | WeakMap |
| Tracking which objects you've already handled, without keeping them alive | WeakSet |
| To iterate, count, or use primitive keys | Map / Set — the weak versions can't |
Small, fixed lookup tables written as literals —
const STATUS_TEXT = { 404: 'Not found', 500: 'Server error' } — are still
objects, and TypeScript types them precisely. Reach for Map when keys are
dynamic, aren't strings, or the collection changes often; Set whenever the
question is "have I seen this?"; and the weak versions when the keys are
objects that should be free to go.