Weak collections
WeakMap and WeakSet do one thing their ordinary counterparts cannot: they let a key be garbage-collected while the collection still exists. Everything else about them is a restriction in service of that. A WeakMap has get, set, has, and delete; a WeakSet has add, has, and delete; neither has a size, neither can be iterated, and neither can be cleared.
There are exactly two reasons to reach for either: attaching data to an object you do not own, with a WeakMap, and marking an object, with a WeakSet. If your reason is not one of those two, you wanted the maps or sets page.
Create programs/weak-collections.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import {
assert,
assertEquals,
assertFalse,
assertStrictEquals,
assertThrows,
} from "@std/assert";
Follow the page as you add and revise the runnable examples below that import.
the methods are the ones you already know
Deno.test("the methods are the ones you already know", () => {
const notes = new WeakMap<object, string>();
const subject = {};
assertStrictEquals(notes.set(subject, "seen"), notes);
assertStrictEquals(notes.get(subject), "seen");
assert(notes.has(subject));
assertStrictEquals(notes.get({}), undefined);
assertStrictEquals(notes.delete(subject), true);
assertStrictEquals(notes.delete(subject), false);
assertStrictEquals(notes.get(subject), undefined);
const marked = new WeakSet<object>();
const flag = {};
assertStrictEquals(marked.add(flag), marked);
assert(marked.has(flag));
assertStrictEquals(marked.delete(flag), true);
assertFalse(marked.has(flag));
});
Check programs/weak-collections.test.ts
running 1 test from ./programs/weak-collections.test.ts
the methods are the ones you already know ... ok (218µs)
ok | 1 passed | 0 failed (1ms)
Every method behaves exactly as its Map or Set equivalent does, including set and add returning the collection so they chain, and delete reporting whether anything went. The fresh {} on the fourth line finds nothing for the reason those pages drilled: keys compare by identity, and a lookalike is a different key. If you know the ordinary collections, you know these.
there is no way to look inside
A Map shows its contents to anything that asks. Predict what the runtime's inspector prints for a WeakMap holding one entry:
Deno.test("there is no way to look inside", () => {
assertStrictEquals(
Deno.inspect(new Map([[{ a: 1 }, "x"]])),
'Map(1) { { a: 1 } => "x" }',
);
assertStrictEquals(
Deno.inspect(new WeakMap([[{ a: 1 }, "x"]])),
'WeakMap(1) { { a: 1 } => "x" }',
);
});
Check programs/weak-collections.test.ts
running 2 tests from ./programs/weak-collections.test.ts
...
there is no way to look inside ... FAILED (8ms)
ERRORS
there is no way to look inside => ./programs/weak-collections.test.ts:31:6
error: AssertionError: Values are not strictly equal.
[Diff] Actual / Expected
- WeakMap { <items unknown> }
+ WeakMap(1) { { a: 1 } => "x" }
FAILURES
there is no way to look inside => ./programs/weak-collections.test.ts:31:6
FAILED | 1 passed | 1 failed (10ms)
error: Test failed
<items unknown> is the runtime admitting it will not show you, with no count in the parentheses either. Correct the prediction, and measure how deep the refusal goes:
Deno.test("there is no way to look inside", () => {
assertStrictEquals(
Deno.inspect(new Map([[{ a: 1 }, "x"]])),
'Map(1) { { a: 1 } => "x" }',
);
assertStrictEquals(
Deno.inspect(new WeakMap([[{ a: 1 }, "x"]])),
"WeakMap { <items unknown> }",
);
const notes = new WeakMap<object, string>();
assertFalse("size" in notes);
assertFalse("clear" in notes);
assertFalse("keys" in notes);
assertFalse(Symbol.iterator in notes);
assertEquals(
Object.getOwnPropertyNames(WeakSet.prototype),
["constructor", "delete", "has", "add"],
);
assertEquals(Object.getOwnPropertyNames(WeakMap.prototype), [
"constructor",
"delete",
"get",
"set",
"has",
"getOrInsert",
"getOrInsertComputed",
]);
});
there is no way to look inside ... ok (279µs)
No size, no clear, no keys, not iterable, and a WeakSet's entire prototype is four names; the WeakMap prototype's seven are the four methods, the constructor, and the upsert pair from the maps page. Two reasons for the missing members, and the second is the interesting one. The practical reason: entries can vanish, because a key nothing else refers to can be collected at a moment nobody controls, so size would be a number that changes on its own and iterating twice could produce two different answers; there is nothing coherent to report. The design reason: it buys a guarantee. A value in a WeakMap can be reached only by code holding both the map and the key, which makes the map a capability rather than a container, and it is why clear is missing too, since a clear would let someone holding only the map affect what a key-holder sees. The restriction is the feature.
only values with an identity can be keys
Objects can be weak keys, and since ES2023 so can symbols. Symbol.for returns a symbol, so predict what it does as a key:
Deno.test("only values with an identity can be keys", () => {
const notes = new WeakMap<WeakKey, string>();
notes.set({}, "an object is fine");
const unregistered = Symbol("mine");
notes.set(unregistered, "so is a symbol");
assertStrictEquals(notes.get(unregistered), "so is a symbol");
assertStrictEquals(notes.get(Symbol("mine")), undefined);
notes.set(Symbol.for("shared"), "a registered one should be too");
assertStrictEquals(
notes.get(Symbol.for("shared")),
"a registered one should be too",
);
});
Check programs/weak-collections.test.ts
running 3 tests from ./programs/weak-collections.test.ts
...
only values with an identity can be keys ... FAILED (268µs)
ERRORS
only values with an identity can be keys => ./programs/weak-collections.test.ts:63:6
error: TypeError: Invalid value used as weak map key
notes.set(Symbol.for("shared"), "a registered one should be too");
^
FAILURES
only values with an identity can be keys => ./programs/weak-collections.test.ts:63:6
FAILED | 2 passed | 1 failed (2ms)
error: Test failed
The registry is the problem. The rule for a weak key has two halves, and a value needs both. Compared by identity, so a key is a unique thing rather than a value something else could equal: two symbols with the same description are two keys, as the Symbol("mine") miss shows, exactly as two objects with the same contents are, from the values and references page. And collectable, so the entry can go away when the key does. A string fails the first half, because it is collectable but compared by value, so attaching data to "text" would attach it to every equal string and nothing could be released while any copy existed. A registered symbol fails the second, because Symbol.for("shared") is deliberately reachable forever through the registry, the trade the symbols page measured in the registry gives up uniqueness, so its entry could never be collected either. Wrap the refusals in assertThrows and pin all three, noting the WeakSet wording differs:
Deno.test("only values with an identity can be keys", () => {
const notes = new WeakMap<WeakKey, string>();
notes.set({}, "an object is fine");
const unregistered = Symbol("mine");
notes.set(unregistered, "so is a symbol");
assertStrictEquals(notes.get(unregistered), "so is a symbol");
assertStrictEquals(notes.get(Symbol("mine")), undefined);
assertThrows(
() => notes.set(Symbol.for("shared"), "registered"),
TypeError,
"Invalid value used as weak map key",
);
assertThrows(
() => (notes as WeakMap<never, string>).set("text" as never, "a string"),
TypeError,
"Invalid value used as weak map key",
);
assertThrows(
() => (new WeakSet() as WeakSet<never>).add("text" as never),
TypeError,
"Invalid value used in weak set",
);
});
only values with an identity can be keys ... ok (320µs)
TypeScript knows most of that rule
The string refusal above needed two casts to reach the runtime, and that is the checker's half of the story:
Deno.test("TypeScript knows most of that rule", () => {
const byName = new WeakMap<string, number>();
byName.set("a", 1);
assertStrictEquals(byName.get("a"), 1);
});
Check programs/weak-collections.test.ts
TS2344 [ERROR]: Type 'string' does not satisfy the constraint 'WeakKey'.
const byName = new WeakMap<string, number>();
~~~~~~
at file:///programs/weak-collections.test.ts:93:30
error: Type checking failed.
The key parameter is constrained to a type called WeakKey, so a string key is caught while you write it rather than when you run it, which is the specification's can-be-held-weakly rule encoded in the type system. It cannot encode all of it. Symbol.for("shared") has the type symbol, the same type as every other symbol, so a registered symbol satisfies WeakKey and still throws:
Deno.test("TypeScript knows most of that rule", () => {
const byObject = new WeakMap<object, number>();
byObject.set({}, 1);
const bySymbol = new WeakMap<symbol, number>();
const key = Symbol("mine");
bySymbol.set(key, 2);
assertStrictEquals(bySymbol.get(key), 2);
assertThrows(
() => bySymbol.set(Symbol.for("shared"), 3),
TypeError,
"Invalid value used as weak map key",
);
});
TypeScript knows most of that rule ... ok (56µs)
The registry is a run-time fact, and the type system has no way to see it. Worth keeping as a precise example of where a type system stops: the rule is not about types, it is about provenance.
attaching data to an object you do not own
Deno.test("attaching data to an object you do not own", () => {
const sizes = new WeakMap<object, number>();
let computations = 0;
function countKeys(subject: object): number {
if (sizes.has(subject)) return sizes.get(subject)!;
computations++;
const count = Object.keys(subject).length;
sizes.set(subject, count);
return count;
}
const subject = { a: 1, b: 2 };
const frozen = Object.freeze({ a: 1 });
assertStrictEquals(countKeys(subject), 2);
assertStrictEquals(countKeys(subject), 2);
assertStrictEquals(computations, 1);
assertStrictEquals(countKeys(frozen), 1);
assertStrictEquals(computations, 2);
});
attaching data to an object you do not own ... ok (44µs)
A cache keyed on the thing being computed about, the first of the two use cases, and the weakness is the whole reason it is safe. Write the same function with an ordinary Map and it works identically and leaks: every object ever passed in is kept alive forever, because a Map holds its keys, and nothing looks wrong while memory grows for as long as the process runs. The WeakMap version releases each entry when its key goes, without being told. Note what this gives you that a property could not, and the frozen call is the proof: you are storing something about the object without touching it, so it works on frozen objects, objects from another library, and objects whose shape somebody else validates.
getOrInsertComputed is that pattern in one call
Deno.test("getOrInsertComputed is that pattern in one call", () => {
const cached = new WeakMap<object, number>();
let recomputations = 0;
function countKeys(subject: object): number {
return cached.getOrInsertComputed(subject, () => {
recomputations++;
return Object.keys(subject).length;
});
}
const subject = { a: 1, b: 2, c: 3 };
assertStrictEquals(countKeys(subject), 3);
assertStrictEquals(countKeys(subject), 3);
assertStrictEquals(recomputations, 1);
});
getOrInsertComputed is that pattern in one call ... ok (41µs)
The same cache with the check-then-get-then-set dance collapsed into one lookup, and no ! left anywhere, by the pair the maps page introduced in the read-or-create dance collapses into one line: both upsert methods exist on WeakMap too, as the prototype pin earlier showed. Use it, and expect to keep reading the three-line version for years, since that is what existing code does.
marking, and the brand check it enables
Deno.test("marking, and the brand check it enables", () => {
const constructed = new WeakSet<object>();
class Registered {
constructor() {
constructed.add(this);
}
describe(): string {
if (!constructed.has(this)) {
throw new TypeError("Incompatible receiver");
}
return "a real one";
}
}
assertStrictEquals(new Registered().describe(), "a real one");
assertThrows(
() => Registered.prototype.describe.call({}),
TypeError,
"Incompatible receiver",
);
assertThrows(
() => Map.prototype.get.call({}, "k"),
TypeError,
"Method Map.prototype.get called on incompatible receiver #<Object>",
);
});
marking, and the brand check it enables ... ok (91µs)
The second use case, and the best code on the page. The constructor records every instance it makes, and the method refuses to run on anything not in that set: since this is whatever the caller supplies, from the the value of this page, a method borrowed with .call gets caught. This is called a brand check, and it is what the built-in classes do internally, which the last assertion shows: Map.prototype.get on a plain object complains about an incompatible receiver rather than misbehaving, and a WeakSet is how you write the same refusal yourself, with the weakness meaning the mark does not keep the object alive. A #private field gives the same guarantee with less machinery now, using #x in value as the test, from the private class members page; the WeakSet remains the tool when the objects are not yours to add fields to.
the privacy pattern that #private replaced
Deno.test("the privacy pattern that #private replaced", () => {
const counters = new WeakMap<object, number>();
const actions = new WeakMap<object, () => void>();
class Countdown {
constructor(count: number, action: () => void) {
counters.set(this, count);
actions.set(this, action);
}
decrement(): void {
const left = counters.get(this)! - 1;
counters.set(this, left);
if (left === 0) actions.get(this)!();
}
}
let fired = false;
const countdown = new Countdown(2, () => {
fired = true;
});
countdown.decrement();
assertFalse(fired);
countdown.decrement();
assert(fired);
assertEquals(Object.keys(countdown), []);
assertEquals(Object.getOwnPropertyNames(countdown), []);
class ModernCountdown {
#left: number;
#action: () => void;
constructor(count: number, action: () => void) {
this.#left = count;
this.#action = action;
}
decrement(): void {
this.#left--;
if (this.#left === 0) this.#action();
}
}
let modernFired = false;
const modern = new ModernCountdown(1, () => {
modernFired = true;
});
modern.decrement();
assert(modernFired);
assertEquals(Object.getOwnPropertyNames(modern), []);
});
the privacy pattern that #private replaced ... ok (78µs)
Two module-level WeakMaps holding what would have been fields, and the instance really is empty: no own properties at all, so nothing to find by inspection, by JSON.stringify, or by a debugger, where the private class members page caught JSON.stringify leaking public fields. ModernCountdown is the same class with today's tools: the same guarantee and the same empty property list, a third of the machinery, no ! on every read, and the data attached to the instance where it belongs rather than held in two module-level maps that must be kept in step. You should not write the old pattern, and you will meet it, so the reason to know it is recognition: its author was not being perverse, because it was the only way to get hard privacy before private fields existed.
none of this is observable
The weakness that justifies both classes cannot be demonstrated from inside the language, which is why this page, alone in the series, contains no test of its central claim: there is no callback, no event, no size to watch shrink, and no assertion can be written that proves an entry was ever collected. That is worth stating rather than leaving as an absence, because the natural response to "I cannot see it working" is to assume you are holding it wrong. Observing collection at all takes WeakRef and FinalizationRegistry, plus a runtime flag to make collection happen on demand, and even then the specification promises nothing about when, which is a separate subject and not this entry's. For everyday purposes, treat the weakness as a property you rely on and never verify.
both constructors take initial contents
Deno.test("both constructors take initial contents", () => {
const first = {};
const second = {};
const notes = new WeakMap([[first, "one"], [second, "two"]]);
assertStrictEquals(notes.get(second), "two");
assert(new WeakSet([first, second]).has(first));
});
both constructors take initial contents ... ok (27µs)
Like their ordinary twins, which is easy to forget given how little else these two classes let you do.
In practice
- Use a
WeakMapto attach data to an object whose lifetime you do not manage, and aWeakSetto mark one. - Prefer a
#privatefield for private state and#x in valuefor a brand check when you own the class. - If you need to count, list, or iterate the members, use an ordinary collection.