· tips · 8 min read
The Art of Immutability: How Closures and Scopes Twist JavaScript Interviews
Master how closures and lexical scope influence immutability in JavaScript interview problems. Learn patterns, pitfalls, and practical techniques-plus sample interview questions and clear solutions-to write predictable, testable code.

What you’ll be able to do after reading this
You’ll walk away able to spot the interview trick in a closure question. You’ll be able to craft genuinely immutable data with JavaScript patterns. You’ll explain why immutability matters in modern apps-and why closures and scope are the mechanism interviewers use to test your understanding.
Let’s start with the result, then unpack how to get there.
Why interviewers love closures and scope (and why you should, too)
Closures reveal whether a candidate understands lexical scope and state. They’re compact, language-specific, and easy to mis-handle. In interviews, a small closure-based puzzle exposes understanding of hoisting, variable capture, and the differences between reference and value immutability.
Immutability - making values unchangeable - is more than a fad. It improves predictability, aids debugging, and makes state management (think React/Redux) far safer. Interviewers often combine closures and scope to probe whether you can implement immutability using language features rather than libraries.
Quick glossary
- Lexical scope: variables are resolved by where functions are defined, not where they’re called. Learn more: https://developer.mozilla.org/en-US/docs/Glossary/Lexical_scoping
- Closure: a function that remembers variables from its creation context. Docs: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures
- Reference vs value immutability: primitives are value-immutable; objects/arrays are reference types and can be mutated unless you prevent it.
Classic interview traps and how closures make them worse
Below are common puzzles-and why candidates trip up.
1) The ‘var in a loop’ trap
This is an interview favorite:
const arr = [];
for (var i = 0; i < 3; i++) {
arr.push(function () {
return i;
});
}
console.log(arr[0](), arr[1](), arr[2]()); // ??Many expect 0 1 2. But because var is function-scoped and the functions close over the same i, the output is 3 3 3 - i becomes 3 by the time the functions run.
Solutions:
- Use
let(block-scoped) so each iteration gets its owni. - Create a closure per iteration using an IIFE or another function.
// with let
const arr = [];
for (let i = 0; i < 3; i++) {
arr.push(() => i);
}
console.log(arr[0](), arr[1](), arr[2()]); // 0 1 2
// with closure / IIFE
const arr2 = [];
for (var j = 0; j < 3; j++) {
(function (k) {
arr2.push(() => k);
})(j);
}
console.log(arr2[0](), arr2[1](), arr2[2]()); // 0 1 2This question tests lexical scope, hoisting behavior of var, and whether you can use closures to capture value, not reference.
2) The counter factory - building immutable API
A common interview asks you to create a counter with increment, decrement, and value without exposing internal state directly.
Here closures shine.
function createCounter(initial = 0) {
let count = initial; // private variable
return {
increment() {
count += 1;
},
decrement() {
count -= 1;
},
value() {
return count;
},
};
}
const c = createCounter(5);
c.increment();
console.log(c.value()); // 6
// Note: there's no way to set c.count directlyThis pattern provides controlled mutation via an API while keeping the internal state private. It’s not immutable in the strictest sense because increment mutates count, but it enforces encapsulation and prevents arbitrary external mutation.
If the requirement is strictly immutable - returning new instances instead of changing state - modify the pattern:
function makeCounter(value = 0) {
return {
value,
increment() {
return makeCounter(value + 1);
},
decrement() {
return makeCounter(value - 1);
},
};
}
const a = makeCounter(1);
const b = a.increment();
console.log(a.value, b.value); // 1, 2Now a never changes: every operation returns a new object. That’s pure immutability.
Shallow vs deep immutability - the trap candidates miss
const obj = { a: 1 } doesn’t make obj immutable. const only prevents reassigning the binding. Properties remain mutable.
const o = { name: 'A' };
o.name = 'B'; // perfectly validCommon interviewer check: “How do you make this truly immutable?” The typical answers are Object.freeze() or libraries.
const frozen = Object.freeze({ a: 1 });
frozen.a = 2; // silently fails in non-strict mode; throws in strict modeBut Object.freeze is shallow. Nested objects can still be mutated.
const nested = Object.freeze({ inner: { a: 1 } });
nested.inner.a = 2; // allowed - shallow freezeDeep freeze example (interviewers may ask you to implement this):
function deepFreeze(obj) {
Object.getOwnPropertyNames(obj).forEach(name => {
const prop = obj[name];
if (prop && typeof prop === 'object') deepFreeze(prop);
});
return Object.freeze(obj);
}Point out performance and practicality concerns: deep freezing large graphs is expensive, and structural sharing (used by persistent data structures) is often a better approach.
Using closures to emulate private immutable state
Closures can enforce true immutability at the API level by never exposing direct references to internal mutable objects. Instead, return copies (or frozen copies) or use controlled accessors.
Example: immutable todo list factory
function createTodoStore(initial = []) {
let todos = initial.slice(); // private
return {
get() {
return todos.slice();
}, // return a shallow copy
add(todo) {
todos = todos.concat(todo);
return this;
},
remove(id) {
todos = todos.filter(t => t.id !== id);
return this;
},
};
}This store keeps todos private. Consumers can read via get() but only get copies. Every mutation replaces the whole list, following immutable patterns.
Advanced patterns seen in interviews
- Revealing module pattern / IIFE modules to create private scope
- Using WeakMap to store private fields for class instances
- ES private fields (
#field) to hide internals
WeakMap private field example:
const _state = new WeakMap();
class SecretCounter {
constructor(n = 0) {
_state.set(this, { n });
}
inc() {
const s = _state.get(this);
s.n += 1;
}
value() {
return _state.get(this).n;
}
}ES private fields (syntax sugar, enforced by runtime):
class C {
#n = 0;
inc() {
this.#n += 1;
}
value() {
return this.#n;
}
}Both techniques restrict direct external mutation, but the semantics differ from closure-bound state in subtle ways interviewers might explore.
When immutability is the right tool (and when it isn’t)
Use immutability when you want:
- Predictable state transitions
- Easy debugging and time-traveling in state (e.g., Redux)
- Thread-safety-like guarantees (concurrent environments)
Avoid gratuitous deep freezing for large objects due to performance. Use structural sharing or libraries (Immer, Immutable.js) when you need efficient immutable updates on complex data.
Useful libraries:
- Immer - https://immerjs.github.io/immer/
- Immutable.js - https://immutable-js.github.io/immutable-js/
Memory and performance concerns with closures
Closures hold references to outer variables. That means memory for those variables can persist longer than you’d expect. Interviewers might ask about leak scenarios or GC implications.
Example: adding event listeners inside closures that reference large objects. If the listener outlives the object, the closure keeps it alive. The fix is to remove listeners or null references when done.
Sample interview questions (with short answers)
- Q: What does this print and why?
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}A: Prints 3 three times. Each callback closes over the same i. By the time callbacks run, the loop is done and i === 3. Use let or capture i in a closure to fix it.
- Q: Make an array of functions that return 0..4.
A: Use let in the loop, or IIFE that captures the loop variable.
- Q: How do you make object X immutable?
A: Explain const vs Object.freeze() (shallow), show deepFreeze and trade-offs; mention libraries for complex structures.
- Q: Build a function that returns incrementers which cannot change each other’s counters.
A: Use closure per factory call. Example: function makeInc() { let n = 0; return () => ++n; }.
- Q: Why might closures cause memory leaks?
A: Because closures keep references to their outer lexical environment, preventing GC of data until closure is collectible. Removing listeners or scopes is the remedy.
How to answer closure-and-immutability interview questions - step-by-step
- Restate the problem to confirm you understood the constraints.
- Clarify whether immutability must be strict (no internal mutation) or just encapsulated (no external access).
- Show the simple solution fast (e.g.,
letfix orObject.freeze()), then discuss edge cases (shallow freeze, nested objects, performance). - When asked for code, write a small, correct example and then explain trade-offs.
- If time permits, propose an alternative (deep freeze, structural sharing, using a library) and why you’d pick it in production.
This ordering demonstrates both practical competence and depth of understanding.
Final thoughts - the interviewer’s angle
Interviewers rarely ask closure puzzles to punish you. They want to know if you can reason about where values live, how they’re captured, and what operations are safe. They want to see whether you can turn language features into predictable APIs.
Be explicit about the guarantees your solution provides. Say out loud if a freeze is shallow, if a closure keeps memory alive, or if your factory returns new immutable instances. The clarity matters as much as the code.
Master closures and scope. Use them to provide safe, predictable APIs or to build immutable patterns. And remember: immutability in JavaScript is an art that balances correctness, ergonomics, and performance. Put the strongest point last: the code that survives production is not the cleverest - it’s the clearest.



