skip to content

You are designing a JavaScript library class whose internal state must not be touched by consumers. Compare private #fields, a module-scoped WeakMap keyed by the instance, and closure-captured variables, and say how you would choose.

level: principalimportance: should knowfreq 28%

answer

  1. all three enforce, they differ in who else can reach
  2. friend access across one module
  3. the wrapper is a different object
  4. serialization sees none of it
  5. factory shape versus class shape

basics

~20 s

Default to #fields: cheapest, clearest, enforced by the engine. Switch to a module-scoped WeakMap when several classes in one module must share access or instances get wrapped in a Proxy. Use closures when the API is a factory, not a class hierarchy.

solid answer

~50 s

All three enforce privacy; they differ in who else can reach the state and what they break. **`#fields`** are the default — no indirection, invisible to reflection and serialization, and lexically scoped to one class body. Their costs are real: a subclass cannot see them, `structuredClone` and `JSON.stringify` drop them, and wrapping an instance in a `Proxy` makes every method that touches one throw, because the proxy is a different object without the brand. A **module-scoped WeakMap** keyed by instance keeps state outside the object entirely, so any collaborating class in the same module can read it — genuine "friend" access — and proxy-wrapped or subclassed shapes are easier to accommodate, at the price of a lookup per access and code that reads less directly. **Closures** in a factory give the tightest boundary but no shared prototype and awkward inheritance. I default to `#`, reach for a WeakMap when I need cross-class access or proxy compatibility, and use closures for factory-shaped APIs.

code

javascript · 18 lines
javascript
const internals = new WeakMap();

class Session {
  constructor(token) { internals.set(this, { token, hits: 0 }); }
  use() {
    const state = internals.get(this);
    state.hits += 1;
    return state.token;
  }
}

// A collaborator in the SAME module can read the internals — friend access
function hitCount(session) { return internals.get(session).hits; }

const s = new Session('abc');
s.use();
console.log(hitCount(s));        // 1
console.log(Object.keys(s));     // [] — nothing stored on the instance

go deeper

for a junior

Know that #fields are the ordinary way to keep class state private in modern JavaScript, and that a WeakMap or a factory closure are the older alternatives that do the same job differently.

for a middle

Explain the mechanics of each: a slot on the instance, a module-level table keyed weakly by the instance, or variables captured by the returned methods — and what each means for memory and access.

for a senior

Name the concrete failure modes you plan around: proxy-wrapped instances throwing on private access, serialization silently dropping state, and subclasses locked out of the parent's internals.

for a principal

Frame it as contract design — which seam you are willing to support forever, whether consumers must be able to wrap or extend your objects, and that none of these mechanisms is a security boundary inside one process.

## The three mechanisms, stated precisely **Private fields.** `class C { #state = ...; }` — the state lives in a slot on the instance, reachable only by writing `#state` inside `C`'s own body. Enforcement is syntactic and total: there is no string key, no computed access, no reflection path. **Module-scoped WeakMap.** `const internals = new WeakMap()` at module top level, `internals.set(this, {...})` in the constructor, `internals.get(this)` in methods. The state is not on the object at all; it is in a table the module owns, and the entry is collected when the instance is. Privacy comes from the fact that nothing outside the module can name `internals`. **Closure capture.** A factory function creates local variables and returns an object whose methods close over them. The state is unreachable because it is not stored anywhere addressable. ## What `#fields` buy — and cost They are the right default. The intent is declared at the point of use, there is no indirection, engines optimise them like ordinary slots, and they are invisible to `Object.keys`, `Reflect.ownKeys`, spread and `JSON.stringify`. Four costs deserve a decision rather than a shrug: - **No friend access.** A private name is scoped to exactly one class body. A companion class in the same file cannot see it, and neither can a subclass. If your design has two types that legitimately cooperate on shared internals, `#` forces you to expose a public seam you did not want. - **Serialization goes dark.** `JSON.stringify` emits nothing for private state, and `structuredClone` does not carry it, so a class holding its data privately needs an explicit `toJSON()` plus a matching rehydration path. - **Proxy incompatibility.** Method calls through a `Proxy` arrive with the proxy as receiver. The proxy carries no private slots, so the first `this.#x` throws a `TypeError`. If consumers are expected to wrap your instances — for observation, membranes, or reactivity — this is disqualifying. - **Subclass friction.** Any state a subclass needs must be exposed through a getter or protected-by-convention property, which is a design decision worth making on purpose rather than discovering late. ## What a WeakMap buys — and costs ```js const internals = new WeakMap(); class Session { constructor(token) { internals.set(this, { token, hits: 0 }); } use() { const s = internals.get(this); s.hits += 1; return s.token; } } ``` Because the map is module-scoped, every class *in that module* can read every other's internals — the friend access `#` refuses to give. Nothing is stored on the instance, so serialization and cloning see a clean public shape, and you decide explicitly what to expose. Keys are held weakly, so entries vanish with their instances and there is no leak. The costs: a map lookup on every access rather than a slot read; code that reads less directly (`internals.get(this).x` instead of `this.#x`); and the discipline that every path which can produce an instance must populate the map, or a method fails with a confusing "cannot read property of undefined". Proxy behaviour also needs thought — a proxy is a different key, so a wrapped instance misses its entry unless you unwrap deliberately, which is at least *possible* to arrange, unlike with `#`. ## What closures buy — and cost A factory returning an object literal of methods gives the strongest boundary and the simplest mental model, and it sidesteps `new`, `this` and construction ordering entirely. The price is structural: every instance allocates its own function objects, there is no shared prototype to patch or extend, `instanceof` needs a substitute, and subclassing becomes composition by hand. That is an API-shape decision, not a privacy decision — choose closures when a factory is the API you wanted anyway. ## How I decide 1. **Start with `#`.** It is the least machinery for the most enforcement. 2. **Do consumers wrap instances in a `Proxy`, or does another class in the module need the internals?** If yes, a module-scoped WeakMap. 3. **Is the public API a factory function rather than a class?** Then closures, and skip the question. 4. **Does the state need to serialize?** Whatever you choose, define the serialized contract explicitly — none of the three round-trips for free. ## The framing worth stating out loud None of these is a security boundary. Code inside the class body, code inside the module, and developer tools all have access; a determined actor in the same process is not stopped by any of them. What you are actually buying is the freedom to change internals in a later release without breaking a consumer who reached in — which is why the interesting question is not "how private can I make it" but "which seam am I prepared to support forever".

  • Why exactly does wrapping an instance with private fields in a Proxy break its methods?
    A method invoked through a proxy runs with the proxy as `this`. Private state lives in slots installed on the target during construction, and the proxy is a separate object that carries none of them, so the brand check on the first `this.#x` fails with a TypeError. Storing state in a module-scoped WeakMap avoids the slot dependency, though you still have to decide whether to key on the proxy or unwrap to the target.
  • Doesn't a WeakMap of internals leak memory as instances pile up?
    No — WeakMap holds its keys weakly, so once an instance is unreachable both the key and its value entry become collectable. The pitfall is the reverse: putting something in the *value* that strongly references the key, or keying on a wrapper while holding the target, which can keep objects alive. Keep the stored record free of back-references to the instance.
  • What do you tell a consumer who says they need access to your private state?
    Treat it as an API gap, not a privacy argument. Find out what they are trying to accomplish and expose a supported seam for it — a getter, an event, a hook, an options object — because the alternative is either an underscore property that becomes permanent contract, or a downstream fork. The value of hard privacy is that this conversation happens before the coupling exists, not after.

saying these in an interview costs you the question

  • Says #fields are a security boundary against untrusted code
  • Claims a WeakMap of internals leaks memory
  • Assumes private fields survive JSON or structuredClone
  • Thinks a Proxy transparently forwards private field access
  • Treats closures and #fields as interchangeable at any API shape

context