JavaScript Basics: Attach Object Metadata with WeakMap
Sometimes you need information about an object without adding properties to it. A WeakMap stores that association separately and does not, by itself, keep an otherwise unreachable object key alive. It is useful when the information matters only while that particular object matters.
These independent examples were tested on Linux with Node.js v24.19.0. They need no packages or network access. Assertions cover object identity, missing values, deletion, and cache reuse. Garbage-collection timing is deliberately not tested.
Prepare a scratch directory
You need Bash, a text editor, Node.js 24 on your PATH, and the mktemp, rm, and rmdir utilities. Keep the same terminal open. Save each named source file as UTF-8 in this directory; .mjs selects ES modules. The setup stops if creating or entering the scratch directory fails.
node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1The tested runtime prints:
v24.19.01. Annotate an object without changing its shape
Save this as attach-object-metadata.mjs:
import assert from 'node:assert/strict';
const notes = new WeakMap();
const document = Object.freeze({ title: 'Setup guide' });
notes.set(document, { reviewed: true });
assert.equal(notes.get(document).reviewed, true);
assert.deepEqual(Reflect.ownKeys(document), ['title']);
assert.equal(notes.has({ title: 'Setup guide' }), false);
assert.throws(() => notes.set('Setup guide', {}), TypeError);
console.log(`Document: ${JSON.stringify(document)}`);
console.log(`Reviewed: ${notes.get(document).reviewed}`);
console.log('A different object has no annotation');Run it:
node attach-object-metadata.mjsOutput:
Document: {"title":"Setup guide"}
Reviewed: true
A different object has no annotationThe frozen document accepts no new properties, but the WeakMap can still associate metadata with it. A new object containing the same title is a different key. WeakMap lookup tracks identity rather than matching fields.
This tutorial uses object keys. The language also permits non-registered symbol keys, but not strings, numbers, or null. Values may be any JavaScript value. The ECMAScript WeakMap specification defines these operations. Keeping the map in a private module scope limits who can retrieve its metadata; exposing the map also exposes that access.
2. Separate a missing entry from an undefined value
Save this as remove-object-metadata.mjs:
import assert from 'node:assert/strict';
const results = new WeakMap();
const pending = {};
const absent = {};
results.set(pending, undefined);
assert.equal(results.get(pending), undefined);
assert.equal(results.get(absent), undefined);
assert.equal(results.has(pending), true);
assert.equal(results.has(absent), false);
console.log(`Pending entry exists: ${results.has(pending)}`);
console.log(`Absent entry exists: ${results.has(absent)}`);
assert.equal(results.delete(pending), true);
assert.equal(results.delete(pending), false);
assert.equal(results.has(pending), false);
console.log(`After deletion: ${results.has(pending)}`);Run it:
node remove-object-metadata.mjsOutput:
Pending entry exists: true
Absent entry exists: false
After deletion: falseget() alone cannot distinguish these two states because both lookups return undefined. Use has() when that value is meaningful. Explicit delete() removes an association immediately and reports whether it existed, independently of garbage collection.
A WeakMap has no entry iterator or size property. It cannot answer “show all tracked objects.” Choose Map if listing entries, counting them, or retaining keys is part of the requirement. Do not keep a second array of every WeakMap key just to list them: that array would keep the objects strongly reachable and undermine the intended lifetime behavior.
3. Cache a result for an immutable input object
Save this as cache-object-summary.mjs:
import assert from 'node:assert/strict';
const summaries = new WeakMap();
let calculations = 0;
function summarize(record) {
if (summaries.has(record)) return summaries.get(record);
calculations += 1;
const summary = `${record.title}: ${record.pages} pages`;
summaries.set(record, summary);
return summary;
}
const guide = Object.freeze({ title: 'Guide', pages: 12 });
assert.equal(summarize(guide), 'Guide: 12 pages');
assert.equal(summarize(guide), 'Guide: 12 pages');
assert.equal(calculations, 1);
const separate = Object.freeze({ title: 'Guide', pages: 12 });
assert.equal(summarize(separate), 'Guide: 12 pages');
assert.equal(calculations, 2);
console.log(summarize(guide));
console.log(`Calculations for two objects: ${calculations}`);Run it:
node cache-object-summary.mjsOutput:
Guide: 12 pages
Calculations for two objects: 2The repeated call for guide reuses its cached string. An equal-looking but separate record needs its own calculation. These records have only primitive fields and are frozen, so the fields used by the calculation cannot change. Mutable input would need an invalidation policy; WeakMap does not detect edits and refresh results automatically.
The example accepts these known local records, not arbitrary external input. It also avoids claiming when memory is reclaimed. If no other strong references keep a key reachable, the weak association allows collection; it does not schedule collection or provide a cleanup notification. Use explicit resource cleanup for files or connections. See the WeakMap reference.
Clean up
In the same terminal, remove only the named files, return to the original directory, and remove the empty scratch directory:
rm -- attach-object-metadata.mjs remove-object-metadata.mjs cache-object-summary.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirAcknowledgements
This post was created with assistance from AI.