JavaScript Classes: Private Fields, Static Methods, Getters
JavaScript classes are prototypes with better syntax and real additions: private fields, field initialisers, statics and accessors. Why methods lose this.
- Course: JavaScript study plan
- Module: Prototypes and classes
- Kind: Lesson
- Reading time: 14 min
- Runtime: Node 16
What is a class in JavaScript?
A class in JavaScript is syntax for a constructor function and its prototype: typeof a class is "function" and its methods live on Class.prototype. It adds what the old syntax lacked — #private members, field initialisers, static blocks — and changes a few rules: class bodies are strict, classes are not hoisted, they must be called with new, and their methods are non-enumerable.
Lesson
class gives the constructor-and-prototype machinery a shape that reads like every other language, plus things the old syntax could not express: real private members, class fields with initialisers, static blocks. It is still prototypes underneath — typeof Point is "function", methods land on Point.prototype — but the syntax also changes a few semantics (strict mode, no hoisting, throw without new). This lesson covers the complete surface as it runs on Node 16, with the corners that trip people: field initialisation order, this in methods passed as callbacks, and what #private actually guarantees.
The full surface
class Account {
static count = 0; // static field — on the class, not instances
static #registry = new Map(); // private static
#balance = 0; // private instance field, initialised per instance
owner; // public field, undefined until assigned
history = []; // public field with initialiser — a NEW array per instance
constructor(owner, opening = 0) {
this.owner = owner;
this.#balance = opening;
Account.count++;
Account.#registry.set(owner, this);
}
deposit(amount) { // method — lives on Account.prototype
if (amount <= 0) throw new RangeError("amount must be positive");
this.#balance += amount;
this.history.push(["deposit", amount]);
return this; // fluent
}
get balance() { return this.#balance; } // accessor — read like a property
set nickname(v) { this._nick = String(v).trim(); }
#audit(kind) { /* private method */ }
static open(owner) { return new Account(owner); } // static method — factory idiom
static lookup(owner) { return Account.#registry.get(owner); }
toString() { return `${this.owner}: ${this.#balance}`; }
}
What the syntax changes
- Strict mode inside the class body, always.
- Not hoisted like a function declaration:
new Account()before theclassline is aReferenceError(temporal dead zone, likelet). - Must be called with
new— a plain call throws. - Methods are non-enumerable:
Object.keys(new Account("a"))lists fields, not methods;for…inskips them. - No commas between members; semicolons after fields are optional but conventional.
Fields and initialisation order
Public fields (history = []) are assigned on the instance before the constructor body runs (for a base class) — each instance gets its own value from re-evaluating the initialiser, which is why history = [] is safe where Account.prototype.history = [] would be shared. In a subclass, fields initialise right after super() returns (next lesson). Field initialisers can reference this and earlier fields. Arrow-function fields (handle = () => {…}) capture this per instance — the standard fix for methods handed to event listeners, at the cost of one closure per instance instead of one shared method.
Private members
#balance is private in the strong sense: not a naming convention, not _balance, but a name the language checks at parse time. Outside the class body acct.#balance is a SyntaxError, not undefined; Object.keys, JSON.stringify, for…in, Reflect.ownKeys and Proxies cannot see it; a subclass cannot reach a parent's #field. Two classes with a #x each have different #xs. #x in obj (Node 16.4+) tests whether an object has the brand — the safe way to check "is this really one of ours" inside a static method. Because privates are per-class brands, an object created by another copy of the same class (two versions of a library) fails the check — the same failure mode instanceof has.
Accessors
get balance() defines a read-only computed property; add set balance(v) for writes. Accessors sit on the prototype like methods. Use them for derived values and validated writes, not for wrapping every field Java-style — a public field is an honest public field. Accessors run code on every read; do not put anything expensive there.
Statics
static members belong to the class object: Account.count, Account.open("x"). Inside a static method this is the class, which matters for subclasses: static create() { return new this(); } makes a SavingsAccount when called as SavingsAccount.create(). static { … } blocks (Node 16.11+) run once when the class is evaluated — for setup that needs several statements.
Class expressions and names
const Temp = class Temperature { }; // named expression: Temp.name is "Temperature"
const Anon = class { }; // Anon.name is "Anon" (inferred)
const mixin = (Base) => class extends Base { }; // classes are values — pass and return them
Methods as callbacks — this again
button.on("click", acct.deposit) detaches the method; inside, this is undefined (strict) and this.#balance throws. Fixes, in order of preference: an arrow at the call site (() => acct.deposit(10)), acct.deposit.bind(acct), or an arrow-function field on the class when the method is always used as a callback. Module 3's five rules apply unchanged.
Classes versus objects and closures
A class earns its keep when there will be many instances sharing behaviour, when identity (instanceof, brand checks) matters, or when a framework expects one. A module that exports functions over a closed-over state is often simpler than a singleton class; a plain object literal beats a class with one instance and no methods. "Prefer composition" is not anti-class — it is anti-*hierarchy*.
Common mistakes
this.history = []inside a method meant to append — reassigning instead ofpush.- Forgetting that fields with initialisers make a new value per instance, and putting the shared cache in a field instead of a
static. - Reading a private of another object of the same class as
other.#x— this is allowed (same class body); reading it outside the class is not. - Passing
obj.methodas a callback and losingthis. - Defining a getter and then trying to assign to it without a setter — silently ignored in sloppy mode,
TypeErrorin strict (which classes are).
Interview angle
- "Are classes just syntactic sugar?" Mostly — prototypes underneath — but with real additions: private members, strict mode, no hoisting,
newrequired, non-enumerable methods. - "
#privateversus_private?" Language-enforced (SyntaxError outside, invisible to reflection) versus a naming convention. - "Where do fields versus methods live?" Fields on the instance, methods on the prototype, statics on the class.
- "Why arrow-function fields?" Per-instance
thiscapture for callbacks; cost: one closure per instance. - "When is
thisthe class?" In static methods — sonew this()builds the subclass that was called.
Key takeaways
- The surface: fields (public/private/static), methods, accessors, statics, static blocks, private methods.
- Classes are strict, not hoisted, throw without
new, and have non-enumerable methods. - Field initialisers run per instance before the constructor body;
history = []is safe, prototype arrays are shared. #namesare enforced privacy and per-class brands;#x in objtests them.- Methods passed as callbacks lose
this— arrow at the call site,bind, or an arrow field.
Common questions
Are JavaScript classes just syntactic sugar?
Mostly — a class still creates a constructor function with methods on its prototype. But it adds features the old syntax could not express: language-enforced #private members, field initialisers, static blocks, always-strict bodies, no hoisting, a required new and non-enumerable methods.
What is the difference between #private and _private in JavaScript?
#private is enforced by the language: obj.#x outside the class body is a SyntaxError, and Object.keys, JSON.stringify, Reflect.ownKeys and Proxies cannot see it. _private is only a naming convention — the property is public and visible to everything.
Where do class fields, methods and static members live?
Fields live on each instance, assigned before a base class's constructor body runs, with a fresh value per instance. Methods and accessors live once on the class's prototype. Static members live on the class object itself, so Account.count and Account.open() are read from the class.
Why does a class method lose this when passed as a callback?
Passing obj.method detaches the function from its object, so inside it this is undefined, because class bodies are strict. Fix it with an arrow at the call site, obj.method.bind(obj), or an arrow-function field that captures this per instance.
What does static mean in a JavaScript class?
A static member belongs to the class, not to its instances: Account.count, Account.open("x"). Inside a static method this is the class it was called on, so new this() builds a subclass when called as SavingsAccount.create(). A static { … } block runs once when the class is evaluated.
Exercises
A bank account with private state
Complete Account: a #balance private field set from the opening amount, a static count incremented per instance, a public history array, deposit(amount)/withdraw(amount) that push ["deposit", amount]/["withdraw", amount] and throw RangeError("amount must be positive") for non-positive amounts and RangeError("insufficient funds in <name>") when overdrawn, a get balance() accessor, and toString() returning <name>: <balance> (<n> transactions). Then run the commands open <name> <amount>, deposit, withdraw, show <name> (prints String(account)) and count (prints accounts=<Account.count>); print error: <message> for a thrown error.
Example:
open ada 100
open bob 20
deposit ada 50
withdraw bob 30
withdraw ada 70
deposit bob -5
show ada
show bob
count
→
error: insufficient funds in bob
error: amount must be positive
ada: 80 (2 transactions)
bob: 20 (0 transactions)
accounts=2Accessors that convert and validate
Write Temperature with a private #celsius, get/set celsius, get/set fahrenheit (the setter converts and reuses the celsius setter), and get kelvin. Setting anything below −273.15 °C throws RangeError("below absolute zero") and leaves the value unchanged. Commands: c <v>, f <v>, show → C=<1dp> F=<1dp> K=<2dp>, inspect → keys=<JSON of Object.keys(t)> json=<JSON.stringify(t)> protoHasCelsius=<Object.hasOwn(Temperature.prototype, "celsius")>. Print error: <message> on a rejected write.
Example: c 100, show, f 32, show, c -300, show, inspect →
C=100.0 F=212.0 K=373.15
C=0.0 F=32.0 K=273.15
error: below absolute zero
C=0.0 F=32.0 K=273.15
keys=[] json={} protoHasCelsius=true
The last line is the lesson: private fields are invisible to Object.keys and JSON, and accessors live on the prototype.
In this module: Prototypes and classes
- The prototype chain — how JavaScript objects inherit
- Constructor functions and what `new` really does
- Class syntax — fields, methods, statics, private and accessors (this lesson)
- Inheritance — extends, super, overriding and extending built-ins
- Property descriptors and the object protocols — toString, valueOf, toJSON, Symbol.toPrimitive
- Checkpoint — Prototypes and classes
← Constructor functions and what `new` really does · Inheritance — extends, super, overriding and extending built-ins →