JAVASCRIPT / PROTOTYPES, CLASSES, AND INHERITANCE
Private fields and methods with hash names
Declare #private fields and methods, read them across instances of one class, and brand-check objects with #field in obj instead of relying on conventions.
What you will learn
- Declare #fields, #methods and static #members and use them only inside the class body
- Read another instance's #fields inside a shared method such as equals or plus
- Brand-check an unknown object with #field in obj instead of try/catch
- Explain why Object.keys, JSON.stringify and obj['#x'] never see a private field
Understanding Private fields and methods with hash names
A name like #count is not a string, not a symbol, and not a property key of any kind. It is a private name bound to the class body it appears in, resolved while the code is parsed, and it addresses a hidden slot that is installed on each instance as the constructor runs. Because the binding is lexical, the only text that can mention #count is text inside that class body; there is no runtime value you could hand to someone else that would reach the slot.
Everything surprising about hash names follows from them not being properties. Object.keys, JSON.stringify, Reflect.ownKeys, object spread and Proxy traps all work on property keys, so none of them observe a private slot. Writing obj['#count'] does not reach it either, it reads or creates an ordinary property whose key is the three-plus-character string "#count". Mentioning an undeclared hash name is a SyntaxError before the first statement runs, and reading a declared one from an object that has no such slot throws a TypeError instead of yielding undefined.
The privacy boundary is the class body, not the individual object. That is why a method can read other.#cents for any instance of its own class, which is what makes plus, equals and compareTo possible without exposing the data. A subclass that declares a field with the same spelling gets a second, unrelated slot on the same instance, so there is no shadowing and no way to reach the parent's slot. Since ES2022 you can test for the brand with #cents in value, which returns false rather than throwing, and note that private methods and accessors are installed per instance too, so they never appear on Class.prototype.
class Counter {
#count = 0;
#step;
constructor(step = 1) {
this.#step = step;
}
#clamp(n) {
return n > 10 ? 10 : n;
}
tick() {
this.#count = this.#clamp(this.#count + this.#step);
return this.#count;
}
static isCounter(value) {
return #count in value;
}
}
const c = new Counter(4);
console.log(c.tick(), c.tick(), c.tick());
console.log(JSON.stringify(Object.keys(c)), JSON.stringify(c));
console.log(Counter.isCounter(c), Counter.isCounter({ count: 0 }));
try {
Counter.prototype.tick.call({});
} catch (e) {
console.log(e.name);
}A hash name is a lexically scoped slot on the instance rather than a property key, so only code inside the declaring class body can name it and any object without that slot throws instead of returning undefined.
Worked examples
Reading another instance's private field
A method may read the private fields of any object built by the same class, but not of a lookalike from another class.
class Money {
#cents;
constructor(cents) {
this.#cents = cents;
}
plus(other) {
return new Money(this.#cents + other.#cents);
}
format() {
return '$' + (this.#cents / 100).toFixed(2);
}
}
class Fake {
#cents = 999;
}
console.log(new Money(1250).plus(new Money(75)).format());
try {
new Money(1250).plus(new Fake());
} catch (e) {
console.log(e.name);
}Example explained
Line 1plus reads other.#cents on a second object because the private name is in scope through all of Money's body, not just for this.
Line 2Fake declares a field spelled #cents, but that is a different private name, so the Money instance finds no matching slot on it.
Line 3The mismatch is a TypeError rather than undefined followed by NaN, which is what makes a private field usable as a class brand.
The string "#secret" is a different thing
Bracket access with a hash-prefixed string creates a plain property that coexists with, and is unrelated to, the private slot.
class Vault {
#secret = 'gold';
reveal() {
return this.#secret;
}
}
const v = new Vault();
v['#secret'] = 'lead';
console.log(v.reveal());
console.log(v['#secret']);
console.log(JSON.stringify(Reflect.ownKeys(v)));
console.log('#secret' in v);Example explained
Line 1v['#secret'] = 'lead' adds an ordinary string-keyed property and leaves the private slot untouched.
Line 2reveal() still returns 'gold' because this.#secret is resolved from the class body rather than by key lookup at runtime.
Line 3Reflect.ownKeys reports only the string key, since private slots are not own properties and no reflection API exposes them.
Line 4'#secret' in v is true about the string property only; the brand check for the real field is #secret in v, written inside Vault.
Same spelling in base and subclass
A subclass declaring #id does not shadow the parent's #id; the instance ends up holding two independent slots.
class Base {
#id = 'base';
fromBase() {
return this.#id;
}
}
class Derived extends Base {
#id = 'derived';
fromDerived() {
return this.#id;
}
}
const d = new Derived();
console.log(d.fromBase(), d.fromDerived());
console.log(JSON.stringify(Object.keys(d)));Example explained
Line 1One instance carries two separate private slots that merely share the text #id.
Line 2fromBase was written inside Base's body, so it always resolves to Base's slot regardless of what Derived declares.
Line 3Derived has no way to read Base's #id at all; naming it outside Base's body is a SyntaxError, not a runtime error.
Line 4Object.keys(d) is empty because neither slot is a property, whatever the depth of the hierarchy.
Important notes
#x in value still requires value to be an object; #x in 42 throws a TypeError, so reject primitives before the brand check.
A subclass's private fields are installed only after super() returns, so if a base constructor calls a method the subclass overrode to read this.#own, that read throws TypeError.
Common mistakes
Writing this.#tickCount when the declaration says #tickcount: an undeclared hash name is a SyntaxError, so the entire file fails to parse instead of that one line producing undefined.
Trying to inspect a private field with obj['#secret'] or Object.keys(obj): you read or create a normal property that happens to be spelled with a #, and it silently has nothing to do with the private slot.
Passing a copy such as {...instance}, a JSON round-trip, or a hand-written test fixture into a method that reads other.#x: copies carry no private slots, so the call throws TypeError rather than comparing values.
Try it yourself
Change, predict, then run
In a browser console, write a Stack class that keeps its items in #items, exposes push, pop and a size getter, and adds static isStack(v) built on #items in v. Then check that JSON.stringify(new Stack()) is {} and that Stack.isStack({...new Stack()}) is false.
Open the JavaScript workspaceCheck your understanding
A method of class Point does return this.#x === other.#x. It works when other is a Point created anywhere in the program, but throws a TypeError when other is { x: 1 }. What does that tell you about #x?
- #x is scoped to Point's class body, so any object carrying Point's private slot can be read, and reading the slot from an object that lacks it throws instead of giving undefined
- #x is per-instance, so other.#x only works because both objects came from the same constructor call site
- The plain object stores x without a hash, so the read yields undefined and the === comparison then throws
- #x is a property named "#x", and plain object literals reject property keys that begin with #
Show answer
Privacy is enforced per class body, not per object: code written inside Point may read the slot on any Point, which is exactly what makes cross-instance comparison legal. Option 2 is tempting but wrong, because where or when the other Point was constructed is irrelevant. Option 3 is wrong because === never throws; the TypeError comes from the private read itself, since a missing private slot is an error rather than undefined.