JavaScript Private Fields Use Brand Checks, Not Property Names
How #private fields enforce class identity across inheritance, proxies, static members, and explicit brand checks.
A JavaScript private field is not a hard-to-reach property. It is a private element tied to one declaration in one class body. Access succeeds only when the receiving object carries that declaration’s private element. This identity check is often called a brand check.
That model explains why bracket notation cannot reach a private field, why two classes may both declare #value without sharing it, and why wrapping an instance in a proxy can break a method that uses private state. The current rules are defined by ECMAScript 2026’s private-element operations.
The declaration creates an identity
Each appearance of a private name belongs to its enclosing class definition. Textual spelling does not create a shared key.
class Meter {
#value = 1;
read() {
return this.#value;
}
}
class Counter {
#value = 99;
read() {
return this.#value;
}
}
const meter = new Meter();
console.log(meter.read());
console.log(Reflect.ownKeys(meter));
console.log(Object.hasOwn(meter, '#value'));
// 1
// []
// false
Private elements do not appear in property descriptors, own-key enumeration, object spread, or the prototype chain. The specification stores them in an object’s internal [[PrivateElements]] list rather than as properties. obj['#value'] therefore asks for an ordinary string-keyed property and has no connection to #value syntax.
Private names also have lexical scope. Code outside the declaring class body cannot even parse object.#value. A public method can deliberately expose some operation on the field, but reflection cannot discover a private name and use it later.
The in form performs a safe brand check
Inside the scope where a private name is available, #name in value tests whether an object carries that element. It returns a boolean instead of trying a read and catching TypeError.
class Ticket {
#serial;
constructor(serial) {
this.#serial = serial;
}
static isTicket(value) {
return typeof value === 'object' && value !== null && #serial in value;
}
}
console.log(Ticket.isTicket(new Ticket('A-17'))); // true
console.log(Ticket.isTicket({ '#serial': 'A-17' })); // false
The object guard matters. The relational-operator rules for private identifiers throw when the right operand is not an object. A public static predicate should normally return false for null and primitives rather than expose that exception.
A private-name check proves only that this particular private element was installed. Constructors that return a different object can install class fields on that returned object in derived construction, so the result is more precise than instanceof but does not prove an object’s prototype lineage.
Inheritance keeps private declarations separate
A subclass inherits public and protected-by-convention behavior, but it cannot refer to a superclass’s private name. It can call an inherited public method, and that method can access the field because the original method contains the correct private name.
class Account {
#balance = 0;
deposit(amount) {
this.#balance += amount;
}
balance() {
return this.#balance;
}
}
class SavingsAccount extends Account {
#balance = 'private to SavingsAccount';
label() {
return this.#balance;
}
}
const account = new SavingsAccount();
account.deposit(25);
console.log(account.balance()); // 25
console.log(account.label()); // private to SavingsAccount
The instance carries two unrelated private fields. A subclass declaration with the same spelling does not override or shadow the superclass element in the ordinary property sense.
This has an API consequence. If subclasses must work with state directly, a native private field is the wrong extension point. A public method, a symbol agreed upon by both classes, or a documented convention may fit better.
Proxies do not inherit the target’s brand
A proxy forwards many ordinary property operations through traps. Private access does not use [[Get]], so a proxy cannot intercept it and does not acquire its target’s private elements.
class Vault {
#code = 7319;
reveal() {
return this.#code;
}
}
const vault = new Vault();
const proxy = new Proxy(vault, {});
console.log(vault.reveal());
try {
proxy.reveal();
} catch (error) {
console.log(error instanceof TypeError); // true
}
The ordinary property lookup finds reveal through the proxy, but the call supplies the proxy as this. The method then looks for Vault’s #code on the proxy and fails.
A get trap can bind methods to the target, but that changes method identity and this behavior. It also turns a general proxy into a class-specific wrapper. Prefer explicit wrapper methods when private state and interception must coexist.
Static private state belongs to the constructor
Static private elements are installed on the exact class constructor, not on instances and not automatically on subclass constructors.
class Sequence {
static #next = 1;
static take() {
return Sequence.#next++;
}
}
class ChildSequence extends Sequence {}
console.log(ChildSequence.take()); // 1
console.log(Sequence.take()); // 2
Using Sequence.#next makes the ownership explicit. If take used this.#next, calling ChildSequence.take() would throw because ChildSequence lacks the private element. Static public properties may be inherited through the constructor prototype chain; static private elements are not found that way.
Private fields are a strong encapsulation tool precisely because they bypass the ordinary property system. Use them when state belongs to one class implementation. Do not choose them when reflection, proxy transparency, subclass access, or object-literal interoperability is part of the contract.