Encapsulation & Private Fields
Advanced · 18 min read · ▶ live playground · ✦ checkpoint
If any code can set account.balance = -500, the object is not protecting its own rules. Internal state is the data an object uses to remember its condition, and encapsulation means keeping that state behind a small set of public fields and methods. JavaScript classes give you real privacy with #fields and #methods.
Make state truly private
A private field is class state whose name starts with # and can only be read or written inside that class body. A private method is a # method that only the class can call. Think of private members as the locked drawer inside the object card: the class has the key, outside code does not.
class GameAccount {
#balance = 0;
constructor(playerName) {
this.playerName = playerName;
}
#formatBalance() {
return `${this.playerName}: ${this.#balance} coins`;
}
deposit(coins) {
if (coins <= 0) {
throw new RangeError("Deposit must be positive");
}
this.#balance = this.#balance + coins;
return this.#formatBalance();
}
spend(coins) {
if (coins > this.#balance) {
return `${this.playerName} needs ${coins - this.#balance} more coins`;
}
this.#balance = this.#balance - coins;
return this.#formatBalance();
}
}
const account = new GameAccount("Mina");
console.log(account.deposit(30));
console.log(account.spend(12));
console.log(Object.hasOwn(account, "#balance"));→ Mina: 30 coins, Mina: 18 coins, then false.
#balance is not a string property named "#balance". It is private class state. Object.hasOwn(account, "#balance") is false because no public property with that string name exists.
Outside code cannot even mention that private name. This stops before the program runs:
class GameAccount {
#balance = 0;
}
const account = new GameAccount();
console.log(account.#balance);→ parsing stops with SyntaxError: Private field '#balance' must be declared in an enclosing class.
Use public methods as the door. deposit and spend can validate every change, so the account never has to accept nonsense just because another file wrote a property.
Give callers safe controls
A getter is a method read like a property, and a setter is a method assigned like a property. They let you keep private state while giving callers clean property-style reads and writes.
class PracticeGoal {
#target = 20;
#score = 0;
constructor(playerName) {
this.playerName = playerName;
}
addScore(points) {
this.#score = this.#score + points;
}
get progressLabel() {
return `${this.playerName}: ${this.#score}/${this.#target}`;
}
get target() {
return this.#target;
}
set target(nextTarget) {
if (!Number.isInteger(nextTarget) || nextTarget <= 0) {
throw new RangeError("Target must be a positive whole number");
}
this.#target = nextTarget;
}
}
const goal = new PracticeGoal("Rae");
goal.addScore(7);
console.log(goal.progressLabel);
goal.target = 12;
goal.addScore(3);
console.log(goal.progressLabel);
console.log(goal.target);→ Rae: 7/20, Rae: 10/12, then 12.
progressLabel looks like a property read, but it runs code. goal.target = 12 looks like assignment, but it runs the setter first, so invalid targets cannot sneak into #target.
Choose names with expressions
A computed property is a property or method whose name comes from an expression inside square brackets. You already used bracket access to choose object keys; classes can use the same idea when the public name is chosen by a constant.
const bonusMethod = "awardBonus";
const moodKey = "currentMood";
class PlayerRecord {
[moodKey] = "steady";
constructor(playerName, score) {
this.playerName = playerName;
this.score = score;
}
[bonusMethod](points) {
this.score = this.score + points;
return `${this.playerName}: ${this.score}`;
}
}
const record = new PlayerRecord("Ira", 16);
console.log(record.currentMood);
console.log(record.awardBonus(5));
console.log(Object.hasOwn(record, "currentMood"));
console.log(Object.hasOwn(PlayerRecord.prototype, "awardBonus"));→ steady, Ira: 21, true, then true.
The field name came from moodKey, so the instance owns currentMood. The method name came from bonusMethod, so the prototype owns awardBonus. Computed names are useful when your class must match a name defined elsewhere, such as a shared command table.
Put class-level tools on the class
A static member is a field or method on the class itself, not on each instance. A factory pattern is a function or static method that creates an object for you through a named setup path.
class PracticeSession {
static maxRounds = 3;
#rounds;
constructor(playerName, rounds) {
this.playerName = playerName;
this.#rounds = rounds;
}
static fromScores(playerName, scores) {
return new PracticeSession(
playerName,
scores.slice(0, PracticeSession.maxRounds),
);
}
static hasSessionBrand(value) {
return #rounds in value;
}
summary() {
return `${this.playerName}: ${this.#rounds.join(" + ")}`;
}
}
const session = PracticeSession.fromScores("Mina", [8, 9, 10, 7]);
console.log(session.summary());
console.log(PracticeSession.maxRounds);
console.log(typeof session.fromScores);
console.log(PracticeSession.hasSessionBrand(session));
console.log(PracticeSession.hasSessionBrand({ playerName: "Mina" }));→ Mina: 8 + 9 + 10, 3, undefined, true, then false.
fromScores belongs to PracticeSession, so you call PracticeSession.fromScores(...), not session.fromScores(...). That named factory hides the setup detail: it trims the scores to maxRounds and returns a ready instance.
#rounds in value is a private brand check. It asks, "Does this object carry this class's private drawer?" A look-alike object with a playerName does not pass.
Try it — protect the mood history
Run this, then inspect tracker.latestMood, tracker.count, and MoodTracker.hasMoodTrackerBrand(tracker) at the › prompt. Try adding another mood with tracker.addMood("calm"); the history changes only through the public method.
The pattern is the same every time: keep data private, expose the smallest useful actions, and let the class protect its own rules.
You now have objects that can guard their internals instead of trusting every caller. Next, the design question gets bigger: once objects are safe, should they reuse behavior through inheritance, or collaborate through composition?
Checkpoint
Answer all three to mark this lesson complete