JavaScript · lesson 12 of 32
Beginner
13- 1 Introduction to JavaScript
- 2 Setting Up JavaScript Development Environment
- 3 Variables in JavaScript: var, let, and const
- 4 JavaScript Data Types
- 5 JavaScript Operators
- 6 Control Flow in JavaScript
- 7 Functions in JavaScript
- 8 Working with Strings in JavaScript
- 9 Regular Expressions in JavaScript
- 10 Arrays in JavaScript
- 11 Objects in JavaScript
- 12 Map, Set, WeakMap, and WeakSet in JavaScript
- 13 JSON in JavaScript: stringify, parse, and What Gets Lost
Intermediate
15- 14 Error Handling in JavaScript
- 15 Dates and Times in JavaScript: Date Pitfalls and Temporal
- 16 Object-Oriented Programming in JavaScript
- 17 Prototypes and Prototype Chain in JavaScript
- 18 Closures in JavaScript
- 19 this, call, apply, and bind in JavaScript
- 20 JavaScript Event Loop Explained
- 21 Asynchronous JavaScript: Callbacks, Promises, and Async/Await
- 22 Iterators and Generators in JavaScript
- 23 JavaScript Modules: ESM and CommonJS
- 24 DOM Manipulation in JavaScript
- 25 Browser Web APIs in JavaScript
- 26 Files, Streams, and the Command Line in Node.js
- 27 Testing JavaScript with Jest and Vitest
- 32 JavaScript Interview Preparation: Top 35 Questions
Advanced
4Also part of JavaScript
1Map, 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
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
Mapkey keeps its original type and, for objects, its identity. MapandSetiterate in insertion order;sizeis a property, and updating an existing key doesn’t move it.- The new Set methods (
union,intersection,difference,symmetricDifference,isSubsetOf,isSupersetOf,isDisjointFrom) return a newSetand leave their inputs unchanged. WeakMap/WeakSetonly accept objects (or, since ES2023, unregisteredSymbols) as keys/members, aren’t iterable, and have nosize— their entries can be garbage collected once nothing else references the key.- Use
WeakMapfor private data or a cache keyed by object identity; useWeakSetfor “have I seen this object” tracking that lives across many calls — the difference from a plainMap/Setonly 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.