How would you attach per-instance private data or metadata to objects you do not control — such as a frozen object or a third-party instance — and why not just set an extra property on the object?
answer
- do not touch the object at all
- module-scoped map, object as key
- frozen objects and name collisions
- invisible to keys, spread and JSON
- metadata dies with the instance
basics
~20 sKeep the data in a WeakMap keyed by the object, held in a scope only your module can reach. Nothing is added to the object, so frozen and third-party objects work, no name can collide, callers cannot see the data, and the entry dies with the object.
solid answer
~50 sPut the state in a module-scoped `WeakMap` and use the object itself as the key: `const state = new WeakMap()`, then `state.set(instance, { retries: 0 })` and `state.get(instance)`. Writing an extra property instead fails in several ways — the object may be frozen or sealed so the write is silently dropped in sloppy mode and throws in strict mode; the name can collide with the owner's own fields now or after a library upgrade; the property shows up in `Object.keys`, `JSON.stringify` and `for...in`, leaking your bookkeeping into their serialised output; and anyone can read or tamper with it. A WeakMap avoids all of that, and it is genuinely private: without a reference to both the map and the key object there is no way to reach the entry, since WeakMap cannot be enumerated. Crucially it also does not leak — when the instance becomes unreachable, its metadata is collected with it, unlike a `Map` registry that would pin every object it ever saw.
code
javascript · 21 linesconst privates = new WeakMap();
class Session {
constructor(user) {
privates.set(this, { user, hits: 0 });
}
touch() {
const s = privates.get(this);
s.hits += 1;
return s.hits;
}
}
const s = new Session('ada');
console.log(s.touch()); // 1
console.log(Object.keys(s)); // []
console.log(JSON.stringify(s)); // {}
const frozen = Object.freeze({ url: '/api' });
privates.set(frozen, { seen: true });
console.log(privates.get(frozen)); // { seen: true }go deeper
Know the shape of the pattern: a WeakMap declared outside the class, keyed by this, storing an object of fields — and that nothing is added to the instance itself.
Be able to list what a plain property breaks: frozen targets, name collisions, visibility in Object.keys/spread/JSON, and readability by any caller — then explain why the WeakMap version avoids each.
Argue the lifetime case in review: a strong registry of foreign objects in a long-running process is a leak by construction, and moving it to a WeakMap removes the cleanup code you would otherwise have to remember to write.
Own the boundary question — who is allowed to observe this state, and whose lifetime governs it. Choosing a weak, module-private store makes that answer structural instead of relying on team convention.
## The pattern The whole technique is one module-scoped map and two lines at each use site: ```js // counter.js const privates = new WeakMap(); export class Counter { constructor(start = 0) { privates.set(this, { count: start }); } increment() { const s = privates.get(this); s.count += 1; return s.count; } } ``` Nothing about the instance changes: `Object.keys(new Counter())` is `[]`, `JSON.stringify` of it is `{}`, and no property name can clash with anything the class or its subclasses define. The map is not exported, so only code inside this module can reach the entries — and because a WeakMap cannot be enumerated, holding the map without holding a particular instance still tells you nothing about that instance. The same shape works for objects you did not create at all: ```js const measured = new WeakMap(); function recordTiming(target, ms) { const list = measured.get(target) ?? []; list.push(ms); measured.set(target, list); } ``` `target` can be a frozen configuration object, an instance produced by a dependency, or a host object — none of which you may mutate. ## Why the obvious alternative fails Setting `obj.__myState = {...}` looks simpler and breaks in four distinct ways. **Frozen and sealed objects reject it.** `Object.freeze(obj)` makes new properties impossible; in sloppy mode the assignment fails silently, and in strict mode — which includes all module code and class bodies — it throws a `TypeError`. Since the object is not yours, you cannot know it will not be frozen tomorrow. ```js const cfg = Object.freeze({ url: '/api' }); cfg.__seen = true; // TypeError in strict mode ``` **Names collide.** Any name you pick is a name the owner might add in a future version, and your assignment would then silently overwrite theirs (or theirs yours). Prefixes and underscores are convention, not protection. **It is visible everywhere.** An added property participates in `Object.keys`, `for...in`, `Object.assign`, spread, `JSON.stringify` and structural equality checks. Bookkeeping that leaks into an API response or a snapshot test is a real bug, not a cosmetic one. **It is not private.** Anything holding the object can read, rewrite or delete your field. A WeakMap entry needs two things — the map reference and the key — and the map reference never leaves your module. ## The lifetime property is the point The alternative that survives review longest is a plain `Map` keyed by the same objects. It fixes collisions and visibility, and then leaks: the map strongly references every key, so an instance registered once is immortal for as long as the module is loaded. In a long-running process — a server handling requests, an app creating and discarding views — that is a steadily climbing heap. `WeakMap` fixes exactly this. Its reference to the key does not keep the key reachable, so when the last real reference to the instance goes away, the instance *and its metadata* become collectable together. You never write cleanup code and you can never forget to. ## Where the pattern needs care The value is held strongly for the key's lifetime, so parking a large buffer or a closure over a big scope in the value keeps it alive as long as the object lives. Keep the stored state small, or store a handle rather than the payload. The map must also be the *only* extra reference. A common self-inflicted wound is registering the same objects in a second, strong collection for diagnostics — a debug array, an event-emitter listener list, a global registry — which pins them and quietly undoes the weakness. Finally, the keys must be objects. If you need to annotate primitives — a request id string, a numeric key — a WeakMap cannot help; you need a strong `Map` plus an explicit eviction policy, because primitives have no lifetime for the collector to track. ## Reading a value that might be absent `get` returns `undefined` for a missing key, which is indistinguishable from a stored `undefined`. When you need to tell those apart — or to lazily initialise — use `has`, or the get-or-create idiom: ```js function stateOf(obj) { let s = privates.get(obj); if (s === undefined) privates.set(obj, (s = { count: 0 })); return s; } ``` That small helper is usually worth having, because it keeps the two-step read/initialise dance out of every method.
- What is the risk of storing a large object as the WeakMap value?Values are held strongly for as long as their key is reachable, so a big buffer or a closure over a large scope stays in memory for the object's whole lifetime. Weakness protects the key's lifetime, not the value's size — keep the stored state small, or store a handle you can refetch.
- If a subclass also wants private state, does it share the same WeakMap?Only if it lives in the same module and reuses that map reference. Each module normally keeps its own WeakMap, and both can key on the same `this` without any interference, because entries are per-map. That isolation is a feature: a subclass in another file cannot read the base module's entries at all.
- Would a symbol-keyed property achieve the same privacy?It gets you collision-freedom and keeps the field out of `Object.keys` and `JSON.stringify`, but it is still a real property: it survives `Object.getOwnPropertySymbols`, it is copied by spread and `Object.assign`, it still fails on a frozen object, and it keeps its data alive exactly as long as the host object — with no leak benefit, since the data is on the object either way.
saying these in an interview costs you the question
- Suggests an underscore-prefixed property is private enough
- Ignores that frozen objects reject new properties
- Uses a plain Map registry and never removes entries
- Says WeakMap entries can be enumerated by whoever holds the map
- Assumes attached properties stay out of JSON.stringify