JAVASCRIPT / MAPS, SETS, SYMBOLS, AND PROXIES
WeakSet and Set logic for membership
Mark objects with WeakSet for leak-free membership checks, and choose Set only when you must count, list, or combine members.
What you will learn
- Mark and test objects with WeakSet.add/has without mutating them or keeping them alive
- Explain why WeakSet has no size or iteration and rejects strings and numbers
- Guard recursive walks over cyclic object graphs with a per-call WeakSet
- Write subset and intersection membership tests using Set.has instead of array scans
Understanding WeakSet and Set logic for membership
Set and WeakSet both answer one question quickly: has this value been added? For objects that question is about identity, not shape, because both collections compare members with SameValueZero, which for objects means "same reference" — two literals with identical fields are two separate members. The real difference is ownership: a Set holds strong references, so every object you add stays reachable for as long as the Set does, which is how a long-lived "already processed" Set turns into a memory leak.
The mental model for WeakSet is a stamp applied from outside the object rather than a container that holds it. It accepts only objects (and, in current engines, unregistered symbols), and holds each reference weakly, so the mark disappears along with the object once nothing else refers to it. That weakness is exactly why the API is just add, has and delete: no size, no iteration, no clear. If you could count or list the members, you could watch the garbage collector work, and your program's output would depend on when the engine happened to collect.
So the choice comes down to what you will do with the collection. If the only operation is has(obj), WeakSet is the better fit whenever the objects' lifetimes belong to somebody else: DOM nodes, values handed in by a caller, items flowing through a stream. The moment you need a count, a list, JSON, or set algebra such as union, intersection or isSubsetOf — all of which must enumerate — you need a Set and you become responsible for emptying it. WeakSet also works in places a marker property cannot: frozen objects, objects you do not own, and objects that get serialized, since membership lives outside the object entirely.
In practice, a WeakSet is a boolean fact about an object stored somewhere the object cannot see.
const activated = new WeakSet();
function activate(widget) {
if (activated.has(widget)) return `${widget.id} already active`;
activated.add(widget);
return `${widget.id} activated`;
}
const a = { id: "panel" };
const b = { id: "panel" };
console.log(activate(a));
console.log(activate(a));
console.log(activate(b));
console.log(`literal lookup: ${activated.has({ id: "panel" })}`);
try {
activated.add("panel");
} catch (err) {
console.log(`${err.constructor.name}: primitives are rejected`);
}
console.log(`size: ${activated.size}, iterable: ${Symbol.iterator in activated}`);A Set owns and can enumerate its members, while a WeakSet is only an invisible mark on objects it does not keep alive, which is why it can never be listed or counted.
Worked examples
Cycle guard in a recursive walk
A per-call WeakSet stops a traversal from following a reference that points back into the graph.
function paths(value, seen, trail) {
if (value === null || typeof value !== "object") return [`${trail}=${value}`];
if (seen.has(value)) return [`${trail}=[cycle]`];
seen.add(value);
return Object.entries(value).flatMap(([k, v]) => paths(v, seen, `${trail}.${k}`));
}
const node = { name: "a", child: { name: "b" } };
node.child.parent = node;
console.log(paths(node, new WeakSet(), "root").join("\n"));Example explained
Line 1seen.has(value) is checked before seen.add(value), so the second arrival at node returns a marker instead of recursing forever.
Line 2The primitive branch returns first, so only objects ever reach add — passing a string there would throw.
Line 3The WeakSet is created at the call site, so each top-level call starts with nothing marked.
Line 4Because the marks are weak, the whole graph becomes collectable as soon as node goes out of scope, even though the walk touched every object.
Membership logic with a real Set
Subset and intersection tests built from Set.has, plus the set-like requirement of the native Set methods.
const admins = new Set(["ana", "raj", "mei"]);
const requested = ["mei", "zoe"];
console.log(`all admins: ${requested.every(n => admins.has(n))}`);
console.log(`overlap: ${requested.filter(n => admins.has(n)).join(",")}`);
console.log(`subset via Set: ${new Set(requested).isSubsetOf(admins)}`);
try {
admins.isSupersetOf(requested);
} catch (err) {
console.log(`${err.constructor.name}: arrays are not set-like`);
}
const shared = { id: 1 };
const seats = new Set([shared]);
console.log(`same ref: ${seats.has(shared)}, copy: ${seats.has({ ...shared })}`);Example explained
Line 1every plus has expresses "is a subset" with one constant-time lookup per candidate instead of scanning an array repeatedly.
Line 2isSubsetOf needs a genuine Set on the left, which is why requested is wrapped first; it also needs a 2024-or-newer engine, while the every/has version runs anywhere.
Line 3isSupersetOf(requested) throws because the native set methods read size and has off the argument, and an array has neither.
Line 4seats.has({ ...shared }) is false: the copy is a different reference, and Set never compares object contents.
Unforgeable brand check on frozen objects
A module-level WeakSet proves an object came from your factory, without touching the object itself.
const tokens = new WeakSet();
function createToken(label) {
const token = Object.freeze({ label });
tokens.add(token);
return token;
}
function redeem(token) {
if (!tokens.has(token)) throw new TypeError("not made by createToken");
return `redeemed ${token.label}`;
}
const real = createToken("gold");
const forged = Object.freeze({ label: "gold" });
console.log(redeem(real));
try {
redeem(forged);
} catch (err) {
console.log(err.message);
}
console.log(`serialized: ${JSON.stringify(real)}`);Example explained
Line 1The object is frozen before it is added, so a marker property such as token.isReal would be impossible — external membership still works.
Line 2forged has identical contents but was never added, so has returns false and the check cannot be faked by copying fields.
Line 3JSON.stringify shows no trace of the mark, so serialization and structural comparisons elsewhere are unaffected.
Line 4A Set here would keep every token ever minted alive for the life of the module; the WeakSet lets dropped tokens be collected.
Important notes
Weakness is unobservable from script: you cannot force collection or ask whether an entry vanished, which is precisely why size, iteration and clear are absent.
Current engines accept unregistered symbols as WeakSet values, but Symbol.for("id") still throws, because registered symbols live forever and could never be collected.
Common mistakes
Passing a primitive, as in seen.add(user.id): WeakSet throws TypeError "Invalid value used in weak set" at runtime, so ID-based deduplication has to use a Set.
Hoisting a traversal's seen WeakSet to module scope: objects marked by an earlier call are reported as cycles on the next call, silently truncating the result instead of erroring.
Reaching for weakSet.size or [...weakSet] while debugging: size is undefined and spreading throws "is not iterable", so there is no way to log what you marked.
Try it yourself
Change, predict, then run
Write deepFreeze(value) that recurses through an object's property values and uses a WeakSet to skip anything it has already frozen, then test it on const a = { n: 1 }; a.self = a; and log Object.isFrozen(a) and Object.isFrozen(a.self).
Open the JavaScript workspaceCheck your understanding
Why does WeakSet deliberately expose no size property and no way to iterate its members?
- Because a WeakSet stores members in a hash table, and hash tables have no defined order
- Because iterating would be too slow once the collection holds many objects
- Because entries disappear when their objects are collected, and exposing them would let a script observe garbage collection timing
- Because a WeakSet stores only a boolean per object rather than a reference to the object
Show answer
Membership vanishes at moments the engine chooses, so any size or iteration would make program output depend on when collection happened. Option 0 is tempting but wrong: Set is hash-based too and still iterates in insertion order, so ordering is not the reason. Option 3 misdescribes the mechanism — a WeakSet does hold a reference to each object, just one that does not keep it alive.