True encapsulation in JavaScript classes, instead of the underscore convention
A field name with a leading underscore like _value was never more than a convention in JavaScript: readable, overwritable, and visible in any debugger from outside at any time. Private class fields and methods with the # syntax implement true, interpreter-enforced encapsulation, with clear limits around inheritance and their own safe way of doing brand checks via the in operator.
Table of Contents
- 1. The problem with the underscore convention
- 2. Basic syntax: fields and methods with #
- 3. True encapsulation: inaccessible even to reflection
- 4. Private methods for internal helper logic that isn't part of the API
- 5. Safe brand checks with the in operator
- 6. Static private fields and methods
- 7. Comparison with earlier encapsulation patterns: closures and WeakMap
- 8. Inheritance: private fields are not visible to subclasses
- 9. Practical example: an account object with enforced invariants
- 10. Summary
- 11. FAQ
1. The problem with the underscore convention
For years, a leading underscore, such as this._value, was the only available signal in JavaScript to mark a field as private, meaning a pure implementation detail. The interpreter, however, never actually enforced that convention: from outside, _value was just as readable, overwritable, and enumerable as any public property, the underscore was purely a statement of intent to other developers, not a technical barrier.
In practice, that regularly caused problems: external code accidentally or deliberately accessed _value directly, bypassing validation logic in setters and breaking internal invariants of the class, without the interpreter ever stepping in. Private class fields with the # syntax close that gap by making access from outside the class impossible at the language level, not merely by convention.
2. Basic syntax: fields and methods with #
A private field is declared in the class body with a leading hash character, such as #balance = 0. This declaration is mandatory, a private field cannot be added dynamically at runtime the way a public property could be via this['newField'] = value. Access inside the class happens perfectly normally via this.#balance.
The same syntax also works for methods: #calculateInterest() defines a method that can only ever be called from inside its own class. It's important that the name after the hash character follows strict identifier rules, and that every reference to a private field, including the declaration itself, must consistently carry the hash character, there is no shorthand without #.
class Account {
#balance = 0;
deposit(amount) {
this.#balance += amount;
this.#log('Deposit', amount);
}
#log(action, amount) {
console.log(`${action}: ${amount} USD, new balance: ${this.#balance}`);
}
}
3. True encapsulation: inaccessible even to reflection
The decisive difference from the underscore convention shows up as soon as code outside the class tries to access a private field: obj.#balance isn't even valid syntax at that point, the parser throws a SyntaxError while loading the script, long before the code ever runs. That's fundamentally different from a runtime TypeError, which could still be caught.
Reflection mechanisms like Object.getOwnPropertyNames(), Object.keys(), or JSON.stringify() also never pick up private fields at all, they appear in no enumeration and in no serialization. Even Reflect.ownKeys() does not return private fields as regular entries. This invisibility is a deliberate design decision that shields private fields from any form of after-the-fact introspection by library code or debugging tools.
const account = new Account();
account.deposit(100);
console.log(Object.keys(account)); // []
console.log(JSON.stringify(account)); // {}
console.log(account.#balance); // SyntaxError while parsing
4. Private methods for internal helper logic that isn't part of the API
Private methods are excellent for separating internal helper logic from a class's public API. While a public method like calculateTotal() remains part of the stable contract with callers, a private helper method like #roundToTwoDecimals() can be renamed, split up, or removed at any time with no regard for backward compatibility, because it does not exist at all for outsiders.
Private getters and setters are also possible, such as get #normalizedFormat(), which makes sense when a derived value is needed at several points inside the class but should never be set or read directly from outside. This clear separation between public interface and private implementation makes refactoring within a class noticeably lower risk.
class PriceCalculator {
#taxRate = 0.19;
calculateTotal(netAmount) {
return this.#roundToTwoDecimals(netAmount * (1 + this.#taxRate));
}
#roundToTwoDecimals(value) {
return Math.round(value * 100) / 100;
}
}
5. Safe brand checks with the in operator
A common problem in generic code is safely checking whether an arbitrary object is actually an instance of a particular class, without risking an error if it is not. The expression #field in object solves exactly that: it returns true if object owns the private field #field, and false if not, without ever throwing a TypeError, even if object isn't an instance of the class at all.
That's especially valuable for so-called brand checks in mixins or library code, where instanceof alone is not enough, because instanceof can be fooled by manipulated prototype chains, while #field in object reliably checks whether the object actually went through the constructor that initializes that particular private field.
class Account {
#balance = 0;
static isAccount(object) {
return #balance in object; // true, false, never an error
}
}
console.log(Account.isAccount(new Account())); // true
console.log(Account.isAccount({})); // false, no error
6. Static private fields and methods
Besides instance fields, static fields and methods can be private too, such as static #instanceCount = 0. Such a field does not belong to a single instance, it belongs to the class itself, and is just as inaccessible from outside as a private instance field, making it well suited for class-wide internal state such as counters, caches, or configuration values.
Static private methods are often used for internal factory logic, such as static #validateInput(data), which gets called by a public static factory method but should never itself be callable directly from outside. It's worth knowing that inside a static method, this refers to the class itself, not to an instance, which matters when accessing static private fields.
7. Comparison with earlier encapsulation patterns: closures and WeakMap
Before private class fields existed, there were two common ways to reproduce true encapsulation in JavaScript. The first was a factory function with closures, where private variables existed as local variables of the function and were only reachable through returned methods. This works reliably, but it forgoes the class syntax entirely and makes inheritance through prototype chains cumbersome.
The second way was a module-wide WeakMap that maps every instance to an object holding its private data, such as privateData.set(this, { balance: 0 }). That works together with class syntax, but it creates extra bookkeeping overhead on every access plus a certain amount of memory overhead from the additional map structure, compared to the direct, interpreter-optimized access to a # field.
8. Inheritance: private fields are not visible to subclasses
Unlike protected in languages such as Java or C#, private fields in JavaScript are only ever visible inside the exact class where they were declared, remaining unreachable even for subclasses. A subclass that inherits from Account cannot directly read or write this.#balance, even if it is itself an instance of Account, because the private field is bound to the Account class, not to the instance in general.
If protected access is genuinely needed for subclasses, the only path is a protected method or a protected getter without a leading hash character, which passes the private field to the outside in a controlled way from within the base class. This deliberate design decision forces a clearer separation between the real base-class implementation and what subclasses are actually allowed to extend.
9. Practical example: an account object with enforced invariants
A bank account is a vivid example, because its central invariant, a balance must never go negative without a controlled path, absolutely needs protection. With a private field #balance, that rule can only ever be enforced through the withdraw() method, direct write access from outside is ruled out at the language level, not merely forbidden by convention.
Combined with a private helper method for validation and a static brand check via the in operator, the result is a class that guarantees its invariants regardless of how carefully the calling code was written, a clear reliability gain over the old underscore convention, where any external access could have broken the invariant.
class Account {
#balance;
constructor(initialBalance = 0) {
this.#balance = initialBalance;
}
withdraw(amount) {
if (!this.#isValidAmount(amount)) {
throw new Error('Invalid amount');
}
this.#balance -= amount;
}
#isValidAmount(amount) {
return amount > 0 && amount <= this.#balance;
}
get balance() {
return this.#balance; // controlled read access
}
}
| Approach | True encapsulation | Access via reflection possible | Performance overhead |
|---|---|---|---|
| Underscore convention (_field) | No, intent only | Yes, fully | None |
| Closures/factory functions | Yes | No | Small, but no class syntax |
| WeakMap per instance | Yes | Practically no | Small overhead from map access |
| Private fields (#) | Yes, enforced by the interpreter | No | Minimal, optimized by the interpreter |
Mironsoft
Modern browser APIs, performance, and maintainable JavaScript
JavaScript that holds up in the real browser, not just in the tutorial?
We review existing frontend code for outdated patterns, unnecessary dependencies, and performance traps, then replace them with modern, native browser APIs that mean less bundle weight and less maintenance burden.
Code Review
Systematically finding outdated patterns, unnecessary dependencies, and memory leaks.
Performance Optimization
Improving bundle size, load time, and runtime performance with modern APIs.
Modernization
Deliberately introducing native browser APIs instead of heavy libraries.
10. Summary
Private Class Fields: The Key Facts at a Glance
Core idea
The hash character makes class fields and methods inaccessible from outside the class at the language level.
Difference from convention
A leading underscore is only an intent, the hash character is actually enforced by the interpreter.
Brand check
The expression #field in object safely checks whether an object is a genuine instance, with no risk of an error.
Key limitation
Private fields are invisible to subclasses, protected access needs an explicit getter method.