Closures for Private State: A Counter You Can't Break
Keep a counter's value in a closure so nothing outside can set it to -5 or "abc". The pattern, how it compares with a class with
A counter sounds too simple to get wrong. Then someone writes counter.count = -5 from another file, or counter.count += "1" and gets "01", and every number after that is nonsense. If a value should only change through your rules, don't give other code a property to change. Keep it in a closure.
The version anyone can break
const counter = {
count: 0,
increment() {
this.count++;
},
};
counter.increment();
counter.count = -5;
counter.increment();
console.log(counter.count);
counter.count += "1";
console.log(counter.count);
-4
-41
Nothing stopped either line. The increment method was a suggestion, not a rule.
The closure version
function createCounter({ start = 0, max = Infinity } = {}) {
let count = start;
return {
increment() {
if (count >= max) throw new Error(`Counter is already at its maximum of ${max}`);
count++;
return count;
},
reset() {
count = start;
},
get value() {
return count;
},
};
}
const tickets = createCounter({ max: 2 });
tickets.increment();
tickets.increment();
console.log(tickets.value);
tickets.value = 100;
tickets.count = 100;
console.log(tickets.value);
try {
tickets.increment();
} catch (error) {
console.log(error.message);
}
2
2
Counter is already at its maximum of 2
count is a local variable of createCounter. The returned object's methods can reach it because they're closures. Nothing else can. tickets.value = 100 is ignored because value only has a getter (in strict mode or a module, it throws instead). tickets.count = 100 just adds a useless new property. The real count stays 2.
💡 Tip: Each call to createCounter makes a new closure with its own count. Two counters never share state.
The same thing with a class
Modern JavaScript also has private class fields, written with #:
class Counter {
#count = 0;
increment() {
return ++this.#count;
}
get value() {
return this.#count;
}
}
const visits = new Counter();
visits.increment();
visits.increment();
console.log(visits.value);
console.log(Object.keys(visits));
2
[]
#count isn't a normal property. It doesn't show in Object.keys, and visits.#count outside the class is a syntax error, so the file won't even run.
Which one to use
- Closure: small factories, a few functions, no
thisto lose when you passtickets.incrementas a callback. It works everywhere JavaScript runs. - Class with
#fields: many methods, inheritance, or a codebase that already uses classes.instanceofworks, and DevTools shows the private fields when debugging.
Both give real privacy. Pick the one that matches the code around it.
The shared-state trap
Private only works if you create the variable inside the factory. If it's outside, every counter shares it:
let shared = 0;
function badCounter() {
return { increment: () => ++shared };
}
const a = badCounter();
const b = badCounter();
a.increment();
a.increment();
console.log(b.increment());
3
b was never incremented, and still says 3. Move let shared = 0 inside the function and each counter gets its own.
âš Warning: Don't return the private value itself if it's an object or array. get items() { return items; } hands out the real array, and anyone can push to it. Return a copy: return [...items];.
Where you'd use this
Rate limiters, retry counters, ID generators, a cart that checks quantities, a module-level cache with a size limit. Anything with a rule like "only goes up", "never negative" or "maximum 5".
If you added set value(n) { count = n; } to the closure version, what would you have to add to it so the counter couldn't be broken again?
About
Build a counter whose value lives in a closure, compare it with a class using private fields, and avoid the shared-state trap. After this, private really means private.
- Responsible
- John G
- Last Update
- 08/30/2026
- Reading Time
- 3 min
Reviews
Sign in to write a reviewComments (0)
Sign in to commentNo comments yet.