In a JavaScript class that declares a private field #id, why does reading other.#id throw when other is not an instance, and what is the recommended way to test whether an arbitrary object carries that field?
answer
- no undefined result for a private name
- the check happens before the read
- instanceof only inspects the prototype
- unforgeable construction mark
- in operator, boolean, no throw
basics
~20 sPrivate access performs a brand check first: if the object has no slot for that private name it throws a TypeError instead of returning undefined. Test membership with the ES2022 syntax #id in other, which yields a boolean without throwing.
solid answer
~40 sA private name is not a string key, so there is no "missing property" result for it. Every read or write of `other.#id` first checks whether the object carries a slot installed by the declaring class — a **brand check** — and throws a `TypeError` when it does not. That makes it a stronger test than `instanceof`, which only inspects the prototype chain: `Object.create(Klass.prototype)` passes `instanceof` but has no fields, and cross-realm objects can fail `instanceof` while being perfectly valid. Since ES2022 you write the check directly as `#id in other`, which evaluates to `true` or `false` without throwing — usable anywhere inside the declaring class body. Note the right-hand side must be an object; `#id in 5` throws a TypeError of its own.
code
javascript · 18 linesclass Id {
#value;
constructor(v) { this.#value = v; }
equals(other) {
if (typeof other !== 'object' || other === null) return false;
if (!(#value in other)) return false;
return this.#value === other.#value;
}
}
const a = new Id(1);
console.log(a.equals(new Id(1))); // true
console.log(a.equals({ })); // false
const fake = Object.create(Id.prototype);
console.log(fake instanceof Id); // true — prototype chain says yes
console.log(a.equals(fake)); // false — no brand, never constructedgo deeper
Know that touching a private field on the wrong object throws rather than giving undefined, and that the check to write instead is #x in obj inside the class that declares #x.
Explain the brand-check step that precedes every private read or write, and contrast it with instanceof, which only inspects the prototype chain and can be satisfied by Object.create.
Use it deliberately: guard binary methods that read another instance's private state, expose unspoofable type predicates, and remember the right operand must be an object or in itself throws.
Reason about identity boundaries — brands are per class evaluation, so duplicated bundles or multiple realms break them, and decide whether your public contract needs a shared well-known symbol instead.
## Why access throws instead of returning undefined Ordinary property access on a missing key yields `undefined`, because a string key that is not present is simply absent from the object and from every prototype in its chain. Private names do not work that way. A private field is stored in an internal slot that only the declaring class can install, during construction. There is no lookup fallback and no prototype chain to consult, so the specification defines the operation as: check whether the object carries this private name; if not, throw a `TypeError`. ```js class Id { #value = 1; read(other) { return other.#value; } // legal syntax — #value is declared here } new Id().read({}); // TypeError: Cannot read private member #value from an object // whose class did not declare it ``` The throw is deliberate: it makes the presence of the field an unforgeable mark of "this object really was constructed by my class". ## Brands, and why they beat instanceof The informal name for that mark is a **brand**. Only the class's own construction path installs it, and nothing outside the class body can add, copy or fake it. Compare with the two common alternatives: - `instanceof` walks the prototype chain. `Object.create(Id.prototype)` produces an object that passes `instanceof Id` while never having run a single field initializer — a shape with no state. `Object.setPrototypeOf(anything, Id.prototype)` does the same on purpose. - A marker property such as `obj.__isId === true` is trivially forgeable and survives spreading and JSON round-trips, so it says nothing about construction. A brand check answers a narrower and more useful question: *was this object actually built by my class?* ## The ergonomic form ES2022 added private-name membership to the `in` operator: ```js class Id { #value; constructor(v) { this.#value = v; } equals(other) { if (!(#value in other)) return false; // no throw, just false return this.#value === other.#value; // safe: brand confirmed } } ``` The `#value in other` form is only writable inside a class body that declares `#value` — the same lexical rule as any other private access — and it produces a boolean rather than throwing. Before it existed, people wrapped the access in `try { obj.#value; return true } catch { return false }`, which worked but used exceptions for control flow and swallowed unrelated errors. One sharp edge: the right operand of `in` must be an object. `#value in 5` or `#value in 'text'` throws a `TypeError`, so guard with a typeof check when the input can be any value: ```js function isIdLike(v) { return (typeof v === 'object' || typeof v === 'function') && v !== null; } ``` ## Where this actually gets used Three situations account for most real usage. **Binary operations on two instances.** `equals`, `compareTo`, `merge` and similar methods legitimately reach into the *other* object's private state; the brand check is what makes that safe when someone passes an arbitrary value. **Type predicates that must not be spoofed.** A library that hands out opaque handles can expose a predicate implemented with `#brand in value`, giving callers a reliable test that no plain object can pass. Because the brand is invisible to enumeration and serialization, a round-tripped copy correctly reports `false` — usually what you want, since the copy has none of the class's guarantees. **Defensive method entry.** A method that will touch private state can check first and throw its own clear error rather than letting a raw `TypeError` about private members reach the caller. ## What it does not tell you A brand check confirms construction by *that specific class in that specific realm*. Two copies of the same library loaded twice produce two class objects with two distinct private names, so an object from one fails the other's check. It also says nothing about the field's *value* — a partially-initialized instance still carries the brand. And it is not a permission system: any code inside the class body has the same access, which is precisely the friend-access escape hatch the design intends.
- Why is a brand check stronger than instanceof for validating an object?`instanceof` only asks whether a prototype appears in the object's chain, and that chain is freely settable: `Object.create(Klass.prototype)` or `Object.setPrototypeOf` makes any object pass while it has run no initializer and holds no state. A private field can only be installed by the declaring class during construction, so its presence is evidence the object really was built by that class.
- How did people write brand checks before the in-operator form existed?They wrapped the access in a try/catch and returned true or false depending on whether it threw. That works, but it uses exceptions for ordinary control flow, is slower on the failing path, and risks swallowing an unrelated error thrown from a getter along the way. The `#x in obj` form replaced it with a plain boolean expression.
- Does a brand check work across two copies of the same library loaded in one page?No. Each evaluation of the class body creates a fresh private name, so instances from copy A do not carry copy B's brand and the check returns false. That is the same duplicate-instance problem that breaks `instanceof` across realms or duplicated bundles; if cross-copy interop matters, you need an explicit shared marker such as a well-known symbol rather than a private field.
saying these in an interview costs you the question
- Says reading a foreign object's private field returns undefined
- Uses instanceof as proof the private fields exist
- Thinks #x in obj can be written outside the declaring class
- Assumes the brand survives spreading or JSON round-trips
- Claims #x in anyValue is safe for primitives too