Private Class Fields: True Encapsulation in JavaScript, Not a Naming Convention
AI generated
JS
() =>
JavaScript · Classes · Encapsulation
Private Class Fields
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.

14 min read #field · #method() in-operator brand check

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.

11. FAQ: Private Class Fields: The Key Facts at a Glance

1What is the difference between _field and #field?
_field is a pure naming convention with no technical effect, the field stays normally readable and writable from outside. #field, by contrast, is enforced by the JavaScript interpreter itself, access from outside the class isn't even valid syntax.
2Can I add a private field dynamically at runtime?
No, private fields must be declared in the class body before they can be used. Unlike public properties, it's not possible to create a private field later through an assignment.
3Do private fields show up in JSON.stringify or Object.keys?
No, private fields are invisible to all standard reflection and serialization mechanisms, they appear neither in Object.keys() nor in JSON.stringify() nor in Reflect.ownKeys() as regular entries.
4What happens if I accidentally access a private field from outside?
The JavaScript parser throws a SyntaxError while loading the script, before the code even runs, because obj.#field outside the declaring class isn't valid syntax at all.
5What is the expression #field in object for?
It enables a safe brand check: it returns true or false depending on whether object owns the private field, and it never throws an error, even if object isn't an instance of the relevant class at all.
6Can subclasses access private fields of the base class?
No, private fields are only visible inside the class that declares them. A subclass has to use a protected method or getter on the base class instead, to access the value in a controlled way.
7What is the difference between private fields and the WeakMap technique?
Both provide true encapsulation, but private fields are managed and optimized directly by the interpreter, while a WeakMap solution creates extra bookkeeping overhead on every access plus a certain amount of memory overhead from the additional map structure.
8Are there private static fields and methods too?
Yes, static #field and static #method() let you define class-wide private data and helper logic that are just as inaccessible from outside as private instance fields.
9Do I have to write the hash character on every access?
Yes, there is no shorthand. Both in the declaration and in every read or write access inside the class, the hash character must consistently be part of the identifier, such as this.#field.
10Is switching from the underscore convention to private fields always worth it?
In the vast majority of cases yes, especially for genuinely safety-relevant invariants. For very simple internal helper values with no risk of external misuse, the practical difference is often small, but the clear guarantee is still usually the better choice.