Skip to main content
JavaScript · lesson 12 of 32
JavaScript beginner Lesson 12 of 32

Map, Set, WeakMap, and WeakSet in JavaScript

Learn JavaScript's Map and Set: object-key coercion, insertion order, the new ES2025 Set operations, and why WeakMap and WeakSet can't be iterated.

By SkillByExample · Updated

javascript map set weakmap collections

Objects and arrays cover most everyday data, but two problems keep coming up: an object key is always coerced to a string, and nothing on an object or array tells you cleanly how many entries it has or lets you iterate it directly. Map and Set fix both. WeakMap and WeakSet trade iteration away for letting their entries be garbage collected. Every example below is a complete file run with node (Node v24.18.1).

Object Keys Are Always Strings

An object converts any key to a string before storing it. Two values that stringify the same way collide.

// object-keys.js
const counts = {};
counts[1] = "one";
counts["1"] = "one (string)";
console.log(counts);
console.log(Object.keys(counts));

const countsMap = new Map();
countsMap.set(1, "one");
countsMap.set("1", "one (string)");
console.log(countsMap);
console.log(countsMap.size);
$ node object-keys.js
{ '1': 'one (string)' }
[ '1' ]
Map(2) { 1 => 'one', '1' => 'one (string)' }
2

counts[1] and counts["1"] are the same property — the second write overwrote the first, so only one key survives. countsMap keeps the number 1 and the string "1" as two distinct keys, because a Map compares keys by value and type (SameValueZero), not by coercing them to strings first.

The same problem is worse with objects as keys, because every object stringifies to the same thing:

// object-as-key.js
const alice = { name: "Alice" };
const bob = { name: "Bob" };

const plain = {};
plain[alice] = "admin";
plain[bob] = "user";
console.log(plain);

const roles = new Map();
roles.set(alice, "admin");
roles.set(bob, "user");
console.log(roles.get(alice));
console.log(roles.get(bob));
console.log(roles.size);
$ node object-as-key.js
{ '[object Object]': 'user' }
admin
user
2

Both alice and bob stringify to "[object Object]", so plain ends up with one key and Bob’s role overwrote Alice’s. roles keys by the object’s identity — alice and bob are different objects, so they’re different keys, regardless of what’s inside them.

Insertion Order and size

A Map’s entries iterate in the order they were inserted, and size is a property, not a method call.

// map-iteration.js
const scores = new Map([
  ["carol", 88],
  ["alice", 95],
  ["bob", 72],
]);

scores.set("dave", 60);
scores.delete("bob");
scores.set("alice", 97); // updates the existing key, doesn't move it

console.log(scores.size);
for (const [name, score] of scores) {
  console.log(name, score);
}
console.log([...scores.keys()]);
console.log([...scores.values()]);
console.log(Object.fromEntries(scores));
$ node map-iteration.js
3
carol 88
alice 97
dave 60
[ 'carol', 'alice', 'dave' ]
[ 88, 97, 60 ]
{ carol: 88, alice: 97, dave: 60 }

bob is gone after delete, and dave was added last, so he comes last — but alice, updated in place, stays in her original second position. size is 3, no parentheses. Object.fromEntries converts back to a plain object when that’s what an API downstream expects.

Set: Unique Values in Insertion Order

A Set is a list of values with no duplicates, built the same way as a Map but without the key half.

// set-basics.js
const tags = new Set(["js", "node", "js", "web", "node"]);
console.log(tags);
console.log(tags.size);
tags.add("testing");
console.log(tags.has("node"));
tags.delete("web");
console.log([...tags]);
$ node set-basics.js
Set(3) { 'js', 'node', 'web' }
3
true
[ 'js', 'node', 'testing' ]

The constructor deduplicated "js" and "node" on the way in — size is 3, not 5. The reason a Set is the standard replacement for array.includes(), when an array is used only to answer “is this in here?”, is speed at scale:

// includes-vs-has.js
const N = 100_000;
const arr = Array.from({ length: N }, (_, i) => `id-${i}`);
const set = new Set(arr);
const target = `id-${N - 1}`; // worst case: the last element

const arrStart = performance.now();
for (let i = 0; i < 200; i++) arr.includes(target);
const arrMs = performance.now() - arrStart;

const setStart = performance.now();
for (let i = 0; i < 200; i++) set.has(target);
const setMs = performance.now() - setStart;

console.log(`array.includes: ${arrMs.toFixed(3)} ms for 200 lookups`);
console.log(`set.has:        ${setMs.toFixed(3)} ms for 200 lookups`);
$ node includes-vs-has.js
array.includes: 114.292 ms for 200 lookups
set.has:        0.017 ms for 200 lookups

includes has to scan from the front of the array, so looking up the last of 100,000 elements 200 times does roughly 20 million comparisons; has looks it up by hash in roughly constant time regardless of how many elements are in the Set. 114.292 / 0.017 is a gap of roughly 6,700x in this run — three to four orders of magnitude. The exact milliseconds vary by machine and run, but the gap, not the precise numbers, is the point, and it only widens as N grows.

The ES2025 Set Operations

Set gained union, intersection, difference, symmetricDifference, isSubsetOf, isSupersetOf, and isDisjointFrom — the operations you’d otherwise hand-roll with filter and spreads.

// set-operations.js
const teamA = new Set(["js", "sql", "docker"]);
const teamB = new Set(["js", "python", "docker", "k8s"]);

console.log("union:", teamA.union(teamB));
console.log("intersection:", teamA.intersection(teamB));
console.log("only teamA:", teamA.difference(teamB));
console.log("symmetric:", teamA.symmetricDifference(teamB));
console.log("A subset of union?", teamA.isSubsetOf(teamA.union(teamB)));
console.log("disjoint from {rust}?", teamA.isDisjointFrom(new Set(["rust"])));
$ node set-operations.js
union: Set(5) { 'js', 'sql', 'docker', 'python', 'k8s' }
intersection: Set(2) { 'js', 'docker' }
only teamA: Set(1) { 'sql' }
symmetric: Set(3) { 'sql', 'python', 'k8s' }
A subset of union? true
disjoint from {rust}? true

Each method returns a new Set — teamA and teamB are unchanged, which is why teamA.union(teamB) can be called again inside isSubsetOf without side effects. intersection is the two teams’ shared skills; difference is what teamA has that teamB doesn’t; symmetricDifference is everything that’s in exactly one of the two. These shipped in Node 22 and in every current browser; older runtimes need new Set([...a, ...b]) for a union or [...a].filter(x => b.has(x)) for an intersection.

WeakMap: Private Data Tied to an Object’s Lifetime

A WeakMap’s keys must be objects (or, since ES2023, unregistered Symbols), and the entry disappears once nothing else references that key — the garbage collector can reclaim both the key and its value. That makes it the standard way to attach private data to an object without adding a visible property.

// weakmap-private.js
const privateData = new WeakMap();

class User {
  constructor(name, ssn) {
    this.name = name;
    privateData.set(this, { ssn });
  }
  maskedSsn() {
    return "***-**-" + privateData.get(this).ssn.slice(-4);
  }
}

const u = new User("Alice", "123-45-6789");
console.log(u.name);
console.log(u.maskedSsn());
console.log(Object.keys(u));
console.log(privateData.size);

try {
  console.log([...privateData]);
} catch (err) {
  console.log(err.message);
}
$ node weakmap-private.js
Alice
***-**-6789
[ 'name' ]
undefined
privateData is not iterable

ssn never becomes a property of u — Object.keys(u) only shows name — yet maskedSsn() can still reach it through privateData, keyed by this. privateData.size is undefined: WeakMap has no size property at all, and spreading it throws, because allowing either would require exposing information about entries that could vanish from under you between one line and the next. (Class private fields, #ssn, cover the same need with less ceremony today; WeakMap predates them and is still what libraries use to attach data to objects they don’t own.)

A second common use is a cache keyed by object identity that never needs manual eviction:

// weakmap-memo.js
const cache = new WeakMap();

function getArea(shape) {
  if (cache.has(shape)) return cache.get(shape);
  console.log("computing area for", shape.label);
  const area = shape.width * shape.height;
  cache.set(shape, area);
  return area;
}

const box = { label: "box", width: 4, height: 5 };
console.log(getArea(box));
console.log(getArea(box));
console.log(getArea({ label: "other", width: 2, height: 2 }));
$ node weakmap-memo.js
computing area for box
20
20
computing area for other
4

The second getArea(box) is a cache hit — no second “computing” line — because it’s the same box object. A Map-based cache would work identically while every shape stays reachable, but would also keep every shape alive forever just because it was once measured. cache being a WeakMap means that once nothing outside the cache references a given shape, that entry can be collected along with it — no cleanup code required.

WeakSet: Tracking Objects Without Keeping Them Alive

A WeakSet is to Set what WeakMap is to Map — membership only, objects (or unregistered Symbols) only, not iterable, no size. The difference from a plain Set only shows up when the set itself outlives the objects passing through it — a short-lived Set created and discarded inside one function call wouldn’t show any difference at all. This one is module-level, called 200,000 times with a fresh, otherwise-unreferenced object each time:

// weakset-seen.js
const seenWeak = new WeakSet();
const seenStrong = new Set();

function markWeak(obj) {
  if (seenWeak.has(obj)) return false;
  seenWeak.add(obj);
  return true;
}
function markStrong(obj) {
  if (seenStrong.has(obj)) return false;
  seenStrong.add(obj);
  return true;
}

const mb = () => Math.round(process.memoryUsage().heapUsed / 1e6);

const base1 = mb();
for (let i = 0; i < 200_000; i++) {
  markWeak({ payload: new Array(50).fill(i) }); // unreachable right after this call
}
globalThis.gc?.();
console.log("200k calls through a WeakSet:", mb() - base1, "MB retained");

const base2 = mb();
for (let i = 0; i < 200_000; i++) {
  markStrong({ payload: new Array(50).fill(i) });
}
globalThis.gc?.();
console.log("200k calls through a plain Set:", mb() - base2, "MB retained");
$ node --expose-gc weakset-seen.js
200k calls through a WeakSet: 0 MB retained
200k calls through a plain Set: 101 MB retained

Every object passed in is discarded by the caller immediately after the call — nothing else ever references it. seenWeak lets each one go as soon as it’s added, so 200,000 calls retain close to 0 MB. seenStrong is a plain Set, so adding an object to it is itself a reference that keeps the object alive for as long as the set exists — 200,000 never-removed objects add up to 101 MB of memory in this run. A WeakSet used the same way — tracking “have I already seen this object” across many calls, such as a long-lived deduplication cache or a guard against revisiting a node while walking a graph with cycles — never accumulates this way.

Key Takeaways

  • An object key is always coerced to a string; a Map key keeps its original type and, for objects, its identity.
  • Map and Set iterate in insertion order; size is a property, and updating an existing key doesn’t move it.
  • The new Set methods (union, intersection, difference, symmetricDifference, isSubsetOf, isSupersetOf, isDisjointFrom) return a new Set and leave their inputs unchanged.
  • WeakMap/WeakSet only accept objects (or, since ES2023, unregistered Symbols) as keys/members, aren’t iterable, and have no size — their entries can be garbage collected once nothing else references the key.
  • Use WeakMap for private data or a cache keyed by object identity; use WeakSet for “have I seen this object” tracking that lives across many calls — the difference from a plain Map/Set only matters once the collection outlives the objects passing through it.

Practice

1. Write topScorer(map) that returns the [name, score] pair with the highest score from the scores Map above.
// p1.js
const scores = new Map([
  ["carol", 88],
  ["alice", 97],
  ["dave", 60],
]);

function topScorer(map) {
  let best = null;
  for (const [name, score] of map) {
    if (best === null || score > best[1]) best = [name, score];
  }
  return best;
}

console.log(topScorer(scores));
$ node p1.js
[ 'alice', 97 ]

Iterating a Map directly with for...of gives [key, value] pairs, so no .entries() call is needed.

2. Add a third team, teamC, and find the skill shared by all three teams, plus the skill only teamC has.
// p2.js
const teamA = new Set(["js", "sql", "docker"]);
const teamB = new Set(["js", "python", "docker", "k8s"]);
const teamC = new Set(["js", "docker", "rust"]);

const sharedByAll = teamA.intersection(teamB).intersection(teamC);
console.log("shared by all three:", sharedByAll);

const onlyTeamC = teamC.difference(teamA.union(teamB));
console.log("only teamC has:", onlyTeamC);
$ node p2.js
shared by all three: Set(2) { 'js', 'docker' }
only teamC has: Set(1) { 'rust' }

The Set methods chain because each one returns a new Set — intersection can be called again on the result of the first intersection.

3. Give User a second private field, clearanceLevel, and a canAccess(requiredLevel) method that reads it without exposing it on the instance.
// p3.js
const privateData = new WeakMap();

class User {
  constructor(name, ssn, clearanceLevel) {
    this.name = name;
    privateData.set(this, { ssn, clearanceLevel });
  }
  maskedSsn() {
    return "***-**-" + privateData.get(this).ssn.slice(-4);
  }
  canAccess(requiredLevel) {
    return privateData.get(this).clearanceLevel >= requiredLevel;
  }
}

const u = new User("Alice", "123-45-6789", 3);
console.log(u.maskedSsn());
console.log(u.canAccess(2));
console.log(u.canAccess(5));
console.log(Object.keys(u));
$ node p3.js
***-**-6789
true
false
[ 'name' ]

Both private fields live in the same object stored under this in privateData; Object.keys(u) still shows only name.

4. Prove that a WeakMap rejects a non-object key.
// p4.js
const wm = new WeakMap();

wm.set({}, "ok with an object key");
console.log("object key accepted");

try {
  wm.set("a string", "not allowed");
} catch (err) {
  console.log(err.constructor.name + ":", err.message);
}
$ node p4.js
object key accepted
TypeError: Invalid value used as weak map key

A plain Map would have accepted the string key without complaint — the object-only restriction is specific to WeakMap, because a primitive like a string can’t be garbage collected the way an object can.

Frequently Asked Questions

Why use a Map instead of a plain object?
A plain object coerces every key to a string (or Symbol), so numbers and objects used as keys collide once converted. A Map keeps a key's original type and identity, preserves insertion order, reports its count through size, and is directly iterable with for...of — none of which an object does reliably.
Can I iterate a WeakMap or WeakSet, or check their size?
No. Neither is iterable and neither has a size property. Their entries can be garbage collected at any time once nothing else references the key, so a stable count or iteration order could change between two lines of code for reasons outside your program's control.
What's the difference between Set and WeakSet?
Set accepts any kind of value, is iterable, reports size, and keeps every member reachable (preventing garbage collection). WeakSet only accepts objects and, since ES2023, unregistered Symbols (created with Symbol(), not Symbol.for()) — never strings, numbers, or other primitives. It isn't iterable, has no size, and lets its members be collected once nothing else references them.
Are the new Set methods like union and intersection available in Node.js?
Yes, since Node 22; this lesson verifies them on Node v24.18.1. They also ship in every current browser. On an older runtime, build the same result by hand: new Set([...a, ...b]) for a union, or [...a].filter(x => b.has(x)) for an intersection.
Does a Map preserve insertion order?
Yes. Map, like Set, iterates entries in the order they were added. Calling set() again on a key that already exists updates its value in place without moving its position in that order.