A class has `static #count = 0` and `static bump() { this.#count++; }`. Calling `Base.bump()` works, but `Sub.bump()` on `class Sub extends Base {}` throws a TypeError. Why, and how do you fix it?
answer
- private names are slots, not properties
- installed on the declaring class only
- brand check fails, so TypeError
- the method inherits, the slot does not
- pin the access to the declaring class
basics
~20 sA private static member is installed only on the class that declares it. Inside the inherited method this is Sub, which carries no #count slot, so the private access fails its brand check and throws a TypeError. Reference the declaring class by name instead.
solid answer
~50 sPrivate names are not properties — they are slots installed on a specific object, and a private *static* slot is installed only on the constructor of the declaring class. `Sub` inherits the method `bump` through the constructor chain, but it does not inherit the `#count` slot, so when the inherited body evaluates `this.#count` with `this === Sub`, the brand check fails and a `TypeError` is thrown rather than yielding `undefined`. The fix is to stop routing the access through the receiver: write `Base.#count++` so the private access always targets the declaring class. If you genuinely want per-subclass counters, private statics are the wrong tool — use a public or symbol-keyed static that each subclass shadows, or a `WeakMap` keyed by constructor. The trap generalises: any static method that touches a private static and can be inherited is a latent `TypeError`.
code
javascript · 11 linesclass Base {
static #count = 0;
static bumpBroken() { return ++this.#count; } // receiver-dependent
static bumpFixed() { return ++Base.#count; } // pinned to the declaring class
static hasSlot(o) { return #count in o; }
}
class Sub extends Base {}
console.log(Base.hasSlot(Base), Base.hasSlot(Sub)); // true false
console.log(Sub.bumpFixed()); // 1
try { Sub.bumpBroken(); } catch (e) { console.log(e.constructor.name); } // TypeErrorgo deeper
Know that # members are genuinely private to the class body and that private statics belong to the class that declares them. Predicting the throw is not expected yet.
Explain that private names are slots rather than properties, that the method is inherited while the slot is not, and that a failed brand check throws a TypeError instead of yielding undefined.
Diagnose it from a stack trace and prescribe the fix by intent: pin the access to the declaring class for shared state, or key state by the receiver when each subclass needs its own. Note that base-only tests never surface this.
Own the design position: whether a class hierarchy should hold mutable class-level state at all, how you keep such a base safely extensible by other teams, and what your review or lint policy is for this.#x inside static methods.
## Private names are slots, not properties The `#name` syntax does not create a property with an odd character in it. It creates a private name bound lexically to the class body, and the engine installs a matching *slot* on specific objects. For a private instance member, the slot is added to each instance as it is constructed. For a private **static** member, the slot is added to exactly one object: the constructor of the class that declared it. That one sentence explains the whole failure. ```js class Base { static #count = 0; static bump() { this.#count++; return this.#count; } } class Sub extends Base {} Base.bump(); // 1 Sub.bump(); // TypeError ``` ## Walking the failure `Sub.bump` resolves fine: `extends` makes `Base` the prototype of the `Sub` constructor object, so the method is found by ordinary prototype lookup. The call then runs `Base`'s method body with `this === Sub`, because `this` in a static is the receiver. Now the body evaluates `this.#count`. Private access performs a **brand check**: does this object actually carry the `#count` slot? `Sub` does not — the slot was installed on `Base` only, and slots are not inherited through the prototype chain the way properties are. A failed brand check is specified to throw a `TypeError`; it never falls back to the prototype chain and never yields `undefined`. (The exact message varies by engine, so do not memorise wording — memorise that it is a `TypeError`.) Contrast the public version, which quietly does something different but also surprising: ```js class PubBase { static count = 0; static bump() { this.count++; } } class PubSub extends PubBase {} PubSub.bump(); PubBase.count; // 0 — the read went through the chain, the write made an own property on PubSub PubSub.count; // 1 ``` So the public field silently splits into two counters, while the private field throws. Both are the same underlying issue — `this` is the subclass — with different failure modes, and the loud one is arguably kinder. ## Fixing it Decide what you actually want. **One counter for the whole hierarchy.** Stop using the receiver; name the declaring class: ```js class Base { static #count = 0; static bump() { return ++Base.#count; } } class Sub extends Base {} Sub.bump(); // 1 — the access targets Base regardless of the receiver ``` This works because the private name is lexically scoped to the class body, and `Base` inside it refers to the class binding — the object that owns the slot. **One counter per subclass.** Private statics cannot express that at all, since only the declaring class has the slot. Use something keyed by the receiver instead: ```js const counts = new WeakMap(); class Base { static bump() { const n = (counts.get(this) ?? 0) + 1; counts.set(this, n); return n; } } class Sub extends Base {} Sub.bump(); Sub.bump(); Base.bump(); // Sub: 2, Base: 1, kept separate ``` A `WeakMap` keyed by the constructor gives per-class state without the slot problem, and keeps the storage out of the public surface. A public static that each subclass redeclares (`static count = 0` in both) also works and is simpler to read, at the cost of being writable from outside. ## Detecting the slot Because the failure is a `TypeError` rather than a value, you cannot probe it with `in` the way you would a property — you use the ergonomic brand check, which is exactly what that syntax is for: ```js class Base { static #count = 0; static hasSlot(o) { return #count in o; } } Base.hasSlot(Base); // true Base.hasSlot(class Sub extends Base {}); // false — proves the slot is not inherited ``` ## The general rule to carry away Any static method that touches a private static **and** can be reached from a subclass is a latent `TypeError` waiting for the first person who writes `Sub.method()`. It is invisible in tests that only ever exercise the base class, and subclassing is exactly the kind of change a different team makes later. Treat `this.#x` inside a static as a smell: either pin the access to the declaring class by name, or move the state somewhere the receiver can legitimately vary.
- Why is this a TypeError rather than undefined, when a missing normal property just reads as undefined?Private access is not a property lookup. It is a slot access guarded by a brand check, and the specification makes a failed check throw. That is deliberate: private members are meant to be an encapsulation boundary, and silently returning undefined would let callers probe for the absence of internals.
- How would you give each subclass its own counter instead?Not with a private static — only the declaring class has that slot. Key the state by the receiver: a module-level `WeakMap` from constructor to count, read and written with `this` as the key. Alternatively give each class its own public static field, accepting that it becomes part of the writable surface.
- Does the same trap apply to private instance members?The symmetric version does. A subclass instance carries the parent's private instance slots, because the parent constructor installs them during construction, so inherited methods work. The failure there is different: passing an unrelated object to a method that reads `#x` fails the brand check, which is why `#x in obj` exists as a safe test.
saying these in an interview costs you the question
- Says the subclass inherits the private static slot
- Expects undefined instead of a thrown TypeError
- Thinks private static means one value per subclass
- Claims adding a constructor to Sub would fix it
- Treats #count as a property reachable via the prototype chain