skip to content

In a JavaScript class body, what does declaring a field as #count = 0 give you that the older convention of naming it _count does not?

level: juniorimportance: must knowfreq 55%

answer

  1. not a naming convention
  2. lexically scoped to the class body
  3. invisible to Object.keys and JSON
  4. TypeError on a foreign object
  5. #x in obj is the safe test

basics

~20 s

A #-prefixed field is privacy enforced by the engine: it can only be named inside the class body that declares it, it is invisible to Object.keys and JSON.stringify, and touching it elsewhere is an error. An underscore name is only a hint.

solid answer

~40 s

`#count` declares a *private name*, not a string property key. The name is lexically scoped to the class body that declares it, so only that class's own methods, getters, setters and field initializers can read or write it; writing `#count` anywhere else is a **SyntaxError** at parse time, and reading `obj.#count` on an object that does not carry the field throws a **TypeError**. Because it is not a string key, it never shows up in `Object.keys`, `Object.getOwnPropertyNames`, `Reflect.ownKeys`, `for...in`, object spread or `JSON.stringify`. `_count`, by contrast, is an ordinary public property with a discouraging name: any consumer can read it, overwrite it, enumerate it, or serialize it by accident. The practical payoff is that private state cannot become someone else's dependency.

code

javascript · 21 lines
javascript
class Counter {
  #count = 0;
  name = 'clicks';

  increment() {
    this.#count += 1;
    return this.#count;
  }

  sameKind(other) {
    return #count in other;
  }
}

const c = new Counter();
c.increment();

console.log(Object.keys(c));        // [ 'name' ]
console.log(JSON.stringify(c));     // {"name":"clicks"}
console.log(c['#count']);           // undefined
console.log(c.sameKind(new Counter()), c.sameKind({})); // true false

go deeper

for a junior

Be ready to say plainly that a #-prefixed field is enforced by the language while an underscore is only a naming habit, and to show reading one from inside a method of the same class.

for a middle

Explain that private names are lexically scoped to the declaring class body and invisible to Object.keys, Reflect.ownKeys and JSON.stringify, and that an undeclared name is a SyntaxError while a foreign object is a TypeError.

for a senior

Show what it costs in a real system: serialization needs an explicit toJSON, structuredClone drops private state, and wrapping an instance in a Proxy makes any method touching a #field throw.

for a principal

Own the API-design call. Decide which state is genuinely internal, and argue when hard privacy earns its keep against the extension, testing and serialization friction it imposes on everyone downstream.

## What the `#` actually declares A leading `#` does not create a property called `"#count"`. It declares a **private name**, a distinct kind of key that has no string form at all. Each instance that runs the declaration gets an internal slot for that name, and the only way to reach the slot is to write the `#count` token in source code that sits inside the class body which declared it. ```js class Counter { #count = 0; increment() { return ++this.#count; } } const c = new Counter(); c['#count']; // undefined — there is no string key by that name ``` Because there is no string key, there is no computed access either: `obj[someExpression]` can never produce a private name. ## Two different failures It is worth keeping these apart, because interviewers probe the difference: - **Undeclared name — SyntaxError, before anything runs.** If a piece of code writes `#count` and no enclosing class body declares `#count`, the script fails to parse: *Private field '#count' must be declared in an enclosing class*. This includes a subclass body trying to touch a parent's private field. - **Declared name, wrong object — TypeError, at run time.** Inside the declaring class body the name is legal, but the object must actually carry the field. `other.#count` where `other` is a plain object literal throws *TypeError: Cannot read private member*. The safe test is the brand check `#count in other`, which yields a boolean. ## What an underscore gives you Nothing the engine knows about. `this._count = 0` creates a normal own property, writable and enumerable like any other. It is documentation aimed at a reader who is willing to cooperate. The moment a consumer needs behaviour you did not expose, `obj._count = 99` works, and now your internal representation is part of your public contract in practice even though it never was on paper. That is the concrete reason the language grew real privacy: not secrecy, but the freedom to change internals later. ## Invisibility to reflection and serialization Private fields are skipped by every enumeration and reflection path: ```js class Account { #balance = 100; owner = 'ada'; } const a = new Account(); Object.keys(a); // [ 'owner' ] Object.getOwnPropertyNames(a); // [ 'owner' ] Reflect.ownKeys(a); // [ 'owner' ] JSON.stringify(a); // '{"owner":"ada"}' { ...a }; // { owner: 'ada' } ``` This cuts both ways. It is exactly what you want for an internal cache or a mutation counter. It is a trap if the field is real state you expect to survive a round trip: `JSON.stringify` silently drops it, so a class holding its data privately needs an explicit `toJSON()` method, and `structuredClone` will not carry it either. ## Private methods and accessors The same `#` prefix works for methods and accessors, and they are brand-checked the same way: ```js class Order { #items = []; #total() { return this.#items.length; } get #isEmpty() { return this.#total() === 0; } add(x) { this.#items.push(x); return this.#isEmpty; } } ``` A private method is not a property of the instance and not a property of the prototype in any observable way — you cannot find it, patch it, or call it from outside. ## Where the boundary really sits Privacy here is a **language** boundary, not a security boundary. Code in the same file that sits inside the class body has full access, which is the intended "friend" escape hatch. Developer tools can still display private fields in an inspector. Treat `#` as the mechanism that stops accidental coupling and lets you refactor internals freely — not as a place to hide a secret from someone who controls the runtime. ## The one interoperability trap worth knowing If you wrap an instance in a `Proxy`, method calls arrive with the proxy as the receiver. The proxy is a different object and carries no private slots, so the first `this.#field` inside the method throws a TypeError. Underscore-named properties are just forwarded and keep working. If a class must be proxy-wrappable, that is a genuine argument for keeping its state elsewhere.

  • Can a subclass read a private field its parent class declared?
    No. A private name is scoped to the class body that declares it, and a subclass body is a different scope, so writing `this.#parentField` there fails to parse with a SyntaxError — not a run-time error you can catch. If a subclass legitimately needs the value, the parent must expose it through a protected-by-convention property, a getter, or a public method.
  • What happens if you read a declared private field off an object that does not have it?
    It throws a TypeError rather than returning `undefined`, because private access performs a brand check first. That makes `try { obj.#x } catch {}` a workable but ugly test, which is why `#x in obj` exists: it evaluates to `true` or `false` without throwing, as long as the right-hand side is an object.
  • Why does JSON.stringify on an instance with private fields produce an object missing that state?
    `JSON.stringify` walks own enumerable string-keyed properties, and a private field is not a string-keyed property at all, so it is invisible to the walk. If the private state must be serialized, give the class an explicit `toJSON()` that returns a plain object built from the private values, and a matching factory or reviver to rebuild instances.

saying these in an interview costs you the question

  • Says # is just a naming convention like an underscore
  • Claims Object.keys or JSON.stringify still shows #fields
  • Thinks a subclass can read the parent's #fields
  • Believes obj['#count'] reaches the private field
  • Says reading a foreign object's #field returns undefined

context