Emmanuel Akinfulubi

Lesson 08 of 1513 min read

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.

Rules that anyone can ignore

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.

Now the rule holds

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


      

There is genuinely nothing to reach for

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.

_underscore is a polite request

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.

A brand check that duck typing cannot forge

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.

An immutable value object

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.

Public, read-only, and hidden, in one class

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.

Editable. Seal it, then run it.

Output


      

Recap

  • Encapsulation means an outsider cannot put your object into a state your methods would refuse.
  • #field is genuinely private: invisible to Object.keys, JSON.stringify and spread, and a SyntaxError to touch from outside.
  • A leading underscore is a comment, not a guarantee. Closures and # fields are the real mechanisms.
  • #field in obj is 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.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.