Privacy here comes from scope, not from objects. A let inside a function is a binding in that call's Environment Record. The spec makes those records impossible for a program to access or manipulate directly — there's no reflection API for them — so the only code that can touch the binding is code written inside the function. Return functions that use it, and they become the variable's entire public interface.
Two shapes:
- Factory function. Every call creates a new environment, so every returned object has independent state. The methods can also call private helper functions that are never returned.
- Module pattern. An IIFE runs the factory once and keeps the result — a singleton. In modern code an ES module does the same job: non-exported top-level bindings stay in the module's own environment, and only exports are visible to importers.
What you get compared with an object property or a _underscore convention:
- Hard privacy.
Object.keys,JSON.stringify, proxies, and property access can't see the binding. - Controlled writes. Validation lives in the only functions that can assign.
- No
this. The methods resolve state lexically, so they can be passed around as callbacks without losing anything.
What it costs:
- Per-instance functions. Each call creates new function objects; class methods are shared on the prototype. For many instances, a class with
#privatefields gives the same hard privacy with shared methods. - Closed set of methods. A function added later isn't lexically inside the factory, so it can't see the state.
- References escape. Returning the private array or object itself hands out mutable access; return copies or read-only views.