JavaScript Private Class Fields Explained
JavaScript private class fields use a # prefix to enforce true encapsulation at the language level, hiding state that closures and underscores never could.
Private class fields are properties and methods declared with a # prefix inside a JavaScript class, and they are only accessible from within that class’s own body — not from subclasses, not from outside code, and not even from Object.keys() or JSON.stringify(). Before this landed in the language, “private” state in a class was a convention (an underscore prefix) or a workaround (a closure). Private fields make it a hard rule the engine enforces.
The problem private fields solve
A class with a leading-underscore convention like this._balance is private in name only. Anything holding a reference to the instance can read or overwrite _balance directly — the underscore is a note to other developers, not a barrier. Closures can hide state properly (see how closures work), but only by moving that state outside the class entirely, which breaks the ergonomics of this and makes the pattern awkward for anything beyond a single factory function.
Private fields give you real encapsulation without leaving the class syntax:
class Account {
#balance = 0;
constructor(openingBalance) {
this.#balance = openingBalance;
}
deposit(amount) {
this.#balance += amount;
return this.#balance;
}
get balance() {
return this.#balance;
}
}
const acct = new Account(100);
acct.deposit(50);
console.log(acct.balance); // 150
console.log(acct.#balance); // SyntaxError outside the class
Outside the class body, #balance isn’t just inaccessible — referencing it is a syntax error, not a runtime undefined. That distinction matters: a typo like acct.#blance fails immediately at parse time instead of silently returning undefined the way a normal missing property would.
Fields, methods, and accessors can all be private
The # prefix isn’t limited to data fields. Methods, getters, setters, and even static members can be private:
class Cache {
#store = new Map();
#isExpired(entry) {
return Date.now() > entry.expiresAt;
}
get(key) {
const entry = this.#store.get(key);
if (!entry || this.#isExpired(entry)) return undefined;
return entry.value;
}
set(key, value, ttlMs) {
this.#store.set(key, { value, expiresAt: Date.now() + ttlMs });
}
}
#isExpired is an internal helper that callers of Cache never need to know about, and can’t call even if they wanted to. This is useful for the same reason a well-designed REST API hides its internal implementation behind a stable interface — the class exposes get and set, and everything else is free to change without breaking anyone.
Checking for a private field: the in operator
Because private fields throw on external access, you can’t just try/catch your way into checking whether an object has one. JavaScript instead extends the in operator to support a brand check:
class Wallet {
#balance = 0;
static isWallet(obj) {
return #balance in obj;
}
}
Wallet.isWallet(new Wallet()); // true
Wallet.isWallet({}); // false, no TypeError
This pattern is common in libraries that need to verify an object was actually constructed by their own class, rather than duck-typed to look similar — a defense against objects that merely resemble the expected shape.
Private fields vs closures vs WeakMap
Before private fields, two patterns did this job. Closures capture variables in a function scope, which works but ties private state to a specific construction pattern rather than the class keyword. A WeakMap keyed by this is closer to true class-based privacy and was the common workaround, but it requires a module-level map and manual bookkeeping for every field.
| Underscore convention | Closures | WeakMap | Private fields (#) | |
|---|---|---|---|---|
| Enforced by engine | No | Yes | Yes | Yes |
Works with class syntax | Yes | No | Yes, with boilerplate | Yes, natively |
Visible in JSON.stringify / Object.keys | Yes | N/A | No | No |
| Subclass access | Yes (bug) | N/A | No | No, even to subclasses |
| Syntax overhead | None | High | Moderate | Low |
Private fields are also inaccessible to subclasses, which is a meaningful difference from protected in languages like Java or TypeScript. If a subclass needs to touch parent state, the parent must expose it through a protected-style accessor method — there’s no native “protected” visibility in JavaScript, only fully private or fully public.
Static private fields and class-level state
The same # syntax works on static members, giving a class truly private shared state that isn’t attached to any instance:
class ConnectionPool {
static #instances = 0;
constructor() {
ConnectionPool.#instances++;
}
static get count() {
return ConnectionPool.#instances;
}
}
ConnectionPool.#instances can only be read or written from inside ConnectionPool — not from a subclass, and not from outside code, even via ConnectionPool.instances.
Where this fits with prototypes
Private fields are a deliberate departure from how the rest of JavaScript works. Every other property lives on the object or its prototype chain and is inspectable by design — that openness is core to the language’s dynamism. Private fields are the one place JavaScript chose strict enforcement over flexibility, because encapsulation is a case where “anyone could bypass this if they tried hard enough” genuinely undermines the guarantee a class is trying to make (an internal invariant, a cache that must stay consistent, a balance that must never go negative from outside code).
The takeaway
Private class fields, written with a # prefix, give JavaScript real, engine-enforced encapsulation for the first time — accessible only inside the declaring class, invisible to serialization and reflection, and off-limits even to subclasses. Reach for them whenever a class has internal state or helper logic that callers should never touch directly; reach for a getter or a public method when you need to expose a controlled view of that state instead.
Tagged
Keep reading
Takina · · 5 min read Event Bubbling vs Capturing in JavaScript, Explained
Event bubbling and capturing are the two directions a DOM event travels. How propagation phases work, when to use each, and how to stop them.
Takina · · 4 min read Object.groupBy() in JavaScript, Explained
Object.groupBy() buckets array items by a key you compute, replacing the reduce() boilerplate developers have written for years. How it works.
Takina · · 4 min read Web Locks API: Coordinating Work Across Browser Tabs
The Web Locks API lets JavaScript acquire named locks shared across tabs, so only one tab does work like a token refresh or a write at a time.