Private Fields & Methods
For years JavaScript had no real way to hide a property — the best you could do was prefix a name with an underscore (_balance) and hope nobody touched it. Private fields (ES2022, shipping in browsers and Node well before that) fix this with the # prefix. A #private member is genuinely inaccessible from outside the class body: it is not on the object’s own enumerable keys, it cannot be reached with bracket notation, and trying to access it externally is a hard syntax error rather than a silent undefined. This is true encapsulation, enforced by the language.
Declaring private fields
A private field is written with a leading # in the class body, optionally with an initializer. You must declare it in the class body before using it — there is no implicit creation. Inside the class you always reference it as this.#name; outside the class the name simply does not exist.
class BankAccount {
#balance = 0;
constructor(initial = 0) {
this.#balance = initial;
}
deposit(amount) {
this.#balance += amount;
return this.#balance;
}
get balance() {
return this.#balance;
}
}
const acc = new BankAccount(100);
acc.deposit(50);
console.log(acc.balance); // exposed through a getter
console.log(Object.keys(acc));
Output:
150
[]
Notice that Object.keys(acc) is empty — #balance is not an own property at all, so it never leaks into iteration, JSON.stringify, or Object.assign. The only way to read it is the controlled balance getter you chose to expose.
Access is restricted to the class body
Reaching for a private field from outside is not a runtime quirk you can probe — it fails to even parse. This is a deliberate design choice: the error surfaces at the earliest possible moment.
class Secret {
#token = "abc123";
}
const s = new Secret();
// console.log(s.#token); // SyntaxError: Private field '#token' must be
// // declared in an enclosing class
Because the restriction is lexical, even bracket access cannot bypass it — s["#token"] just looks up a normal string property named "#token", which does not exist and returns undefined. There is no string-key escape hatch the way there is for _-prefixed conventions.
Private fields vs the underscore convention
The _name convention and a real #name field look superficially similar but differ in every way that matters.
| Aspect | _name (convention) | #name (private field) |
|---|---|---|
| Enforced by language | No — just a hint | Yes |
| Accessible from outside | Yes (obj._name) | No (SyntaxError) |
Shows in Object.keys / JSON | Yes | No |
| Reachable via bracket notation | Yes | No |
| Collides with subclass names | Possible | Never (scoped per class) |
Tip: A
#fieldis scoped to the exact class that declares it. A subclass cannot see or accidentally shadow its parent’s private fields, which eliminates a whole category of inheritance name clashes.
Private methods and accessors
The # prefix is not limited to data. You can mark methods, getters, setters, and even static members as private. Private methods are perfect for internal helpers that should never be part of the public API.
class Temperature {
#celsius = 0;
constructor(c) {
this.#celsius = c;
}
// private helper — invisible outside the class
#toFahrenheit() {
return this.#celsius * 9 / 5 + 32;
}
get #label() {
return `${this.#celsius}C`;
}
describe() {
return `${this.#label} = ${this.#toFahrenheit()}F`;
}
}
const t = new Temperature(25);
console.log(t.describe());
Output:
25C = 77F
Static private members work the same way and live on the class itself — useful for shared internal counters or caches that no external code should mutate.
class Id {
static #next = 1;
#value;
constructor() {
this.#value = Id.#next++;
}
get value() {
return this.#value;
}
}
console.log(new Id().value, new Id().value, new Id().value);
Output:
1 2 3
Checking for a private field with #x in obj
Accessing a private field that an object does not have throws a TypeError. To safely test whether an object is a genuine instance carrying a given private field, ES2022 added the ergonomic brand check: #field in obj. It evaluates to true only when obj was constructed by the class (or a subclass) that declares #field, making it a reliable way to detect “is this really one of ours?”
class Wallet {
#funds = 0;
static isWallet(obj) {
return #funds in obj;
}
}
const real = new Wallet();
const fake = { funds: 0 };
console.log(Wallet.isWallet(real));
console.log(Wallet.isWallet(fake));
console.log(Wallet.isWallet(null) === false || "guarded"); // in handles non-objects? no
Output:
true
false
guarded
The brand check never throws for normal objects — it returns false for anything lacking the field, which is exactly what you want for duck-typing-resistant validation. (Note: #funds in obj does require obj to be an object; guard primitives first if needed.)
Best Practices
- Reach for
#privatefields whenever state should be an internal implementation detail — they give you encapsulation the underscore convention only pretends to offer. - Expose controlled access through getters/setters or methods rather than making fields public; this lets you validate writes and change internals later without breaking callers.
- Use private methods (
#helper()) for internal logic so your public surface stays small and intentional. - Prefer
#field in objovertry/catchorinstanceofwhen you need a robust, forge-proof brand check. - Remember private fields are excluded from
JSON.stringify,Object.keys, and spread — if you need serialization, expose it explicitly via atoJSON()method. - Keep private names scoped per class; do not assume a subclass can read a parent’s
#field, because it cannot.