Skip to Content
Closures for Private State: A Counter You Can't Break
Advanced
0 likes
Tutorials

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 this to lose when you pass tickets.increment as a callback. It works everywhere JavaScript runs.
  • Class with # fields: many methods, inheritance, or a codebase that already uses classes. instanceof works, 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
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.