skip to content

A createLogger(config) factory captures the config object it was given. Weeks later, a caller mutates config.level after several loggers were created, and every existing logger changes behaviour. Explain why this happens and how you would design the factory so captured state cannot be changed underneath it.

level: seniorimportance: should knowfreq 40%

answer

  1. reference captured, property read late
  2. rebinding invisible, mutation visible
  3. destructure at creation time
  4. copy or clone before capturing
  5. freeze to make drift throw

basics

~20 s

The closure captured the parameter binding, which holds a reference to the caller's object, so property mutations are visible immediately to every logger. To stop it, read the values you need into local bindings at creation time, or copy or freeze the object before capturing it.

solid answer

~50 s

Capturing `config` captures a binding that holds a **reference** to the caller's object. Every logger resolves `config` when it runs and then reads the property, so a later `config.level = "debug"` is seen by all of them at once — they were never given copies. Note the two separate mechanisms: rebinding the caller's variable to a new object is *not* seen (the parameter is its own binding), but mutating the object that both bindings point at *is*. The fixes follow from that. Destructure at creation time — `const { level } = config;` — and close over the primitive, so the factory reads the configuration exactly once. If you need the whole object, take a defensive copy (`{ ...config }`, or `structuredClone` for nested data) or `Object.freeze` it so mutation fails loudly in strict mode. If liveness is actually wanted, keep it and say so in the API.

code

javascript · 9 lines
javascript
function createLogger(config) {
  return (msg) => {
    if (config.level === "debug") console.log(msg);
  };
}
const config = { level: "info" };
const log = createLogger(config);
config.level = "debug";
log("drift"); // logs — every logger sees the mutation

go deeper

for a junior

Understand that closing over an object means sharing it: another part of the program changing that object changes what your function sees, because the properties are read when the function runs.

for a middle

Explain the two-step read — resolve the binding, then read the property — and why reassigning the caller's variable is invisible while mutating the object is not.

for a senior

Show how you would find this in a running system, and pick the right defence for the case: destructure primitives at creation, deep-copy configuration, or freeze in development so the offending write throws.

for a principal

Decide the contract: whether components take a snapshot of configuration or a live view, make that explicit in the API surface rather than leaving it to capture semantics, and consider the operational value of runtime reconfiguration against reproducibility.

## What the closure actually captured ``` function createLogger(config) { return (msg) => { if (config.level === "debug") console.log(msg); }; } const config = { level: "info" }; const log = createLogger(config); config.level = "debug"; log("hello"); // logs — the closure re-reads config.level ``` The arrow function's body has one free identifier, `config`. That resolves to the parameter binding of the `createLogger` call, which holds a reference to the caller's object. Two reads happen at call time: resolve the binding, then read `.level` off whatever object it denotes. Neither read was frozen at creation. ## Rebinding versus mutating — two different questions These are constantly conflated, and separating them is what makes a strong answer. - **Rebinding the caller's variable** (`config = { level: "error" }` in the caller) is invisible to the logger. The parameter is a distinct binding created by the call and initialised from the argument value; it keeps pointing at the original object. - **Mutating the object** (`config.level = "error"`) is fully visible, because both bindings denote the same object and the property is read late. So the parameter gave you a snapshot of the *reference*, and nothing more. For a primitive argument that snapshot is the whole value and you are safe; for an object it protects almost nothing that matters. ## Diagnosing it The symptom is behaviour that changes without any of the affected code being called or reconfigured — every logger, cache, or validator built from one shared options object flips at the same moment. Because nothing throws and no assignment appears near the misbehaving code, people chase the wrong module for a long time. Useful moves: search for every write to the shared object, not every construction of the product; temporarily `Object.freeze` the options object in a development build so the offending write throws in strict mode; or log the object identity at construction and at use to confirm it is one shared instance rather than several. ## Designing the factory so this cannot happen **Read what you need at creation time.** The cheapest and clearest fix: ``` function createLogger(config) { const level = config.level; // resolved once, right here return (msg) => { if (level === "debug") console.log(msg); }; } ``` Now the closure captures `level`, a `const` binding holding a primitive. Nothing anyone does to `config` afterwards can reach it. Destructuring in the parameter list — `function createLogger({ level })` — does the same thing and documents the dependency in the signature. **Copy when you genuinely need the whole object.** `const opts = { ...config }` protects the top level; nested objects are still shared, so use `structuredClone(config)` when the configuration is deep. Both cost a copy per construction, which is normally irrelevant for a factory called a handful of times. **Freeze to make violations loud.** `Object.freeze` on the object you keep turns silent drift into a `TypeError` in strict mode (module code and class bodies are strict by default), which converts a mysterious behaviour change into a stack trace pointing at the writer. It is shallow, so freeze nested objects too if that matters. **Or keep it live, deliberately.** Sometimes shared mutable configuration is the feature: a runtime log-level switch should affect existing loggers. Then the right answer is not to defend against it but to make it explicit — accept a getter or a small `config.get()` API rather than a bare object, so the contract reads as "this is re-read on every call" instead of leaving the next reader to guess from the closure. ## The general rule to state Capture is by binding, and a binding to an object is a shared handle. If you want a value that cannot change out from under the closure, you must reduce it to something immutable at capture time — a primitive in a `const`, a copy, or a frozen object. Anything else is a live view, and a live view is a contract you should be making on purpose. ## How to answer out loud Name the mechanism (binding to a shared reference, property read at call time), separate rebinding from mutation, then give the ladder of fixes: destructure the primitives you need, copy or clone when you need the object, freeze to fail loudly, and keep it live only when liveness is the intended behaviour.

  • If the caller reassigns their variable to a whole new config object, do existing loggers switch to it?
    No. The parameter is its own binding, initialised from the argument value at call time, so it keeps pointing at the original object. Only mutation of the object both bindings share is visible. That asymmetry is why "pass by reference" is a misleading phrase here — the reference itself was copied into the parameter.
  • Does Object.freeze fully protect a captured configuration object?
    Only the top level. `Object.freeze` makes the object's own properties non-writable and non-configurable, but a nested object it points at stays mutable. Deep protection needs a recursive freeze or a deep copy via `structuredClone`. Freezing also only throws in strict mode; in sloppy mode the write silently does nothing.
  • When is live capture of shared configuration the right design?
    When runtime reconfiguration is a feature — flipping a log level or a feature flag and having already-created consumers respond. The problem is not liveness but implicitness. Express it in the API: hand the factory an accessor, a small settings object with a `get`, or a subscription, so the re-read is visible at the call site rather than an accident of closure capture.

saying these in an interview costs you the question

  • Says objects are passed by reference so reassignment is also seen
  • Claims a const parameter would prevent the mutation
  • Thinks the closure copied the config at creation
  • Believes spreading once protects nested configuration
  • Says the fix is to create the loggers later

context