Encapsulation
Some things belong on the dashboard where anyone can reach them. Others belong sealed inside the engine. Deciding which is which is the first real design skill in OOP.
By the end: Hide internal state behind a deliberate public surface.
A conductor can sell any seat on the bus. He cannot open the gearbox, and that is on purpose. If anyone standing in the park could adjust the gearbox, the bus would break down on the road and nobody would know why.
Encapsulation is the same decision in code. You choose what the outside world can touch, and you make everything else unreachable rather than just frowned upon.
First, the failure
Here is a class with no encapsulation at all. Every property is public, so every rule it tries to enforce can be walked straight around.
class Account {
constructor(owner, balance) {
this.owner = owner;
this.balance = balance;
}
withdraw(amount) {
if (amount > this.balance) throw new RangeError('Insufficient funds.');
this.balance -= amount;
return this.balance;
}
}
const acct = new Account('Amaka', 100);
try {
acct.withdraw(500);
} catch (err) {
console.log('withdraw refused:', err.message);
}
// ...and then this happens anyway.
acct.balance = -9999;
console.log('balance is now', acct.balance);
// Or worse, a typo silently invents a property and the real one never changes.
acct.balence = 50;
console.log('balance:', acct.balance, '| typo lives on:', acct.balence);
Output
Private fields
A field whose name begins with # is private. The hash is part of the name, not a modifier. It is invisible and unreachable outside the class body, and the restriction is enforced by the language rather than by convention.
class Account {
#balance;
constructor(owner, opening) {
this.owner = owner;
if (opening < 0) throw new RangeError('Opening balance cannot be negative.');
this.#balance = opening;
}
get balance() {
return this.#balance;
}
deposit(amount) {
this.#assertAmount(amount);
this.#balance += amount;
return this;
}
withdraw(amount) {
this.#assertAmount(amount);
if (amount > this.#balance) {
throw new RangeError(`Cannot withdraw ${amount}; balance is ${this.#balance}.`);
}
this.#balance -= amount;
return this;
}
// Methods can be private too.
#assertAmount(amount) {
if (typeof amount !== 'number' || Number.isNaN(amount)) {
throw new TypeError('Amount must be a number.');
}
if (amount <= 0) throw new RangeError('Amount must be positive.');
}
}
const acct = new Account('Amaka', 100);
acct.deposit(50).withdraw(30);
console.log('balance:', acct.balance);
try { acct.withdraw(500); } catch (err) { console.log('refused:', err.message); }
try { acct.deposit(-5); } catch (err) { console.log('refused:', err.message); }
try { acct.deposit('ten'); } catch (err) { console.log('refused:', err.message); }
Output
class Account {
#balance = 100;
get balance() { return this.#balance; }
}
const acct = new Account();
console.log('own keys:', Object.keys(acct));
console.log('JSON:', JSON.stringify(acct));
console.log('spread:', { ...acct });
console.log('is #balance in there?', 'balance' in acct, Object.hasOwn(acct, '#balance'));
// Reaching for it from outside is a syntax error, not a runtime miss.
// Uncomment the next line and the whole panel refuses to parse.
// console.log(acct.#balance);
Output
The old convention, and why you will still meet it
Before # existed, the convention was a leading underscore. It communicates intent and enforces nothing.
class Account {
constructor(balance) {
this._balance = balance; // "please do not touch"
}
get balance() { return this._balance; }
}
const acct = new Account(100);
acct._balance = -9999; // nothing stops this
console.log(acct.balance);
console.log('and it shows up everywhere:', Object.keys(acct), JSON.stringify(acct));
Output
Checking whether an object is really one of yours
A private field gives you a reliable brand check. Only an object actually constructed by your class has the field, so #field in obj cannot be faked by something that merely looks similar.
class Ticket {
#serial;
constructor(serial) { this.#serial = serial; }
static isTicket(value) {
return #serial in value; // true only for real Tickets
}
get serial() { return this.#serial; }
}
const real = new Ticket('A-1');
const fake = { serial: 'A-1' };
console.log('real:', Ticket.isTicket(real));
console.log('fake:', Ticket.isTicket(fake));
console.log('but the fake passes a naive check:', 'serial' in fake);
Output
Freezing, for value objects
Sometimes the right answer is that nothing can change after construction. Object.freeze(this) at the end of a constructor gives you an immutable object, which is the safest thing to pass around.
class Seat {
constructor(row, number) {
this.row = row;
this.number = number;
Object.freeze(this);
}
get label() { return `${this.row}${this.number}`; }
// Changes return a new Seat rather than mutating this one.
movedTo(row) { return new Seat(row, this.number); }
}
const a = new Seat('D', 12);
console.log(a.label);
// A frozen object refuses writes. Refusing means throwing, because class
// bodies and modules are always strict. In a sloppy-mode script the same
// assignment would have been ignored in silence, which is far worse.
try {
a.row = 'Z';
} catch (err) {
console.log(err.name + ':', err.message);
}
console.log('unchanged:', a.label);
const b = a.movedTo('F');
console.log('new seat:', b.label, '| original untouched:', a.label);
console.log('frozen?', Object.isFrozen(a));
Output
A worked design decision
Encapsulation is not "make everything private". It is deciding, property by property, who needs what. Here is that decision written out.
class Trip {
// Hidden: internal bookkeeping nobody outside should see or set.
#sold = new Set();
#capacity;
// Public and mutable: genuinely fine for anyone to change.
notes = '';
constructor(route, capacity) {
this.route = route; // public, but only sensible to set once
this.#capacity = capacity;
}
// Read-only: derived, so allowing assignment would be meaningless.
get sold() { return this.#sold.size; }
get remaining() { return this.#capacity - this.#sold.size; }
get soldOut() { return this.remaining === 0; }
// The only door into #sold, and it enforces every rule.
book(seat) {
if (this.soldOut) throw new RangeError(`${this.route} is fully booked.`);
if (this.#sold.has(seat)) throw new RangeError(`Seat ${seat} is already taken.`);
this.#sold.add(seat);
return this;
}
toString() { return `${this.route}: ${this.sold}/${this.#capacity} sold`; }
}
const trip = new Trip('Lagos - Abuja', 3);
trip.book('1A').book('1B');
trip.notes = 'morning run, luggage on the roof';
console.log(String(trip));
console.log('remaining:', trip.remaining, '| fully booked:', trip.soldOut);
try { trip.book('1B'); } catch (err) { console.log('refused:', err.message); }
trip.book('2A');
try { trip.book('2B'); } catch (err) { console.log('refused:', err.message); }
Output
Your turn: seal the leak
The class below tracks seats but leaks its state. Convert seatsLeft and hasBookings to private fields, expose seatsLeft as a read-only getter, and make book the only way the count can change. Then prove from outside that the count cannot be forced.
Output
Recap
- Encapsulation means an outsider cannot put your object into a state your methods would refuse.
#fieldis genuinely private: invisible toObject.keys,JSON.stringifyand spread, and aSyntaxErrorto touch from outside.- A leading underscore is a comment, not a guarantee. Closures and
#fields are the real mechanisms. #field in objis a brand check that a look-alike object cannot forge.- The work is deciding per property: public, read-only through a getter, or hidden entirely.