The Encapsulation Concept
Think of a capsule: medicine is sealed inside a protective shell. You swallow the capsule and the medicine does its job β but you never directly touch the active ingredient. In programming, encapsulation means:
- Internal state is hidden behind a controlled interface.
- Consumers call public methods to read or change state.
- The class guarantees its invariants (e.g., balance can never be negative).
Without it, any code that holds a reference to your object can set account.balance = -99999 directly. With encapsulation, the class enforces validation on every write, making invalid state impossible to create by accident.
Private Class Fields (#field)
Prefix a field name with # to make it private. Private fields must be declared at the top of the class body before they are used, and are completely inaccessible outside the class β the engine enforces this at runtime, not just by convention.
class Temperature {
#celsius; // private field declaration
constructor(celsius) {
this.#celsius = celsius;
}
// Public getters β read-only from the outside
get celsius() { return this.#celsius; }
get fahrenheit() { return this.#celsius * 9 / 5 + 32; }
get kelvin() { return this.#celsius + 273.15; }
// Public setter with validation
set celsius(value) {
if (value < -273.15) {
throw new RangeError('Temperature cannot be below absolute zero (-273.15 Β°C)');
}
this.#celsius = value;
}
toString() {
return `${this.#celsius}Β°C / ${this.fahrenheit.toFixed(1)}Β°F / ${this.kelvin.toFixed(2)} K`;
}
}
const temp = new Temperature(100);
console.log(temp.toString());
console.log(temp.fahrenheit);
temp.celsius = 37;
console.log(temp.celsius);
try {
temp.celsius = -300; // throws RangeError
} catch (e) {
console.log(e.message);
}
// Direct access throws SyntaxError at parse time:
// console.log(temp.#celsius);
100Β°C / 212.0Β°F / 373.15 K 212 37 Temperature cannot be below absolute zero (-273.15 Β°C)
Private Methods (#method)
Private methods work the same way β prefix with # and they become invisible outside the class. Use them for internal helper logic that should never be part of the public API.
class PasswordManager {
#password;
#attempts = 0;
static #MAX_ATTEMPTS = 5;
constructor(initialPassword) {
this.#password = this.#hash(initialPassword);
}
// Private helper β not part of public API
#hash(plaintext) {
// Simplified hash for demo (use bcrypt in production!)
return [...plaintext].reduce((acc, ch) => acc + ch.charCodeAt(0), 0).toString(16);
}
#isLocked() {
return this.#attempts >= PasswordManager.#MAX_ATTEMPTS;
}
verify(plaintext) {
if (this.#isLocked()) {
throw new Error('Account locked after too many failed attempts');
}
const match = this.#hash(plaintext) === this.#password;
if (!match) this.#attempts++;
else this.#attempts = 0;
return match;
}
change(oldPassword, newPassword) {
if (!this.verify(oldPassword)) throw new Error('Wrong password');
this.#password = this.#hash(newPassword);
return true;
}
get isLocked() { return this.#isLocked(); }
}
const pm = new PasswordManager('secret123');
console.log(pm.verify('wrong')); // false
console.log(pm.verify('secret123')); // true
pm.change('secret123', 'newpass');
console.log(pm.verify('newpass')); // true
false true true
Getters and Setters with Validation
Accessors (get / set) let you expose a property-like API while running logic on every read or write. Pair them with private fields to enforce invariants.
| Feature | Public field | Private field + accessor |
|---|---|---|
| Direct mutation | Yes β no control | No β only via setter |
| Validation on write | Manual, easily bypassed | Enforced in setter |
| Computed read | No | Yes β getter can compute |
| Read-only | With Object.freeze only | Yes β omit setter entirely |
| Browser support | All modern browsers | ES2022 / Node 12+ |
When a getter/setter is named name, name the private backing field #name. They can share the same identifier root because the # prefix makes them distinct.
WeakMap-Based Privacy (Legacy Pattern)
Before private class fields, developers stored private data in a WeakMap keyed on this. The WeakMap is closed over in the module scope, invisible from outside. This pattern is still found in older codebases.
const _private = new WeakMap();
class Counter {
constructor(start = 0) {
_private.set(this, { count: start });
}
increment() { _private.get(this).count++; }
decrement() { _private.get(this).count--; }
get value() { return _private.get(this).count; }
}
const c = new Counter(10);
c.increment();
c.increment();
c.decrement();
console.log(c.value); // 11
console.log(c.count); // undefined β truly hidden
11 undefined
The WeakMap pattern works but is verbose and has higher memory overhead. Use private class fields (#) in all new code β they are cleaner, faster, and enforced by the engine.
Practical BankAccount Class
This comprehensive example brings together private fields, private methods, getters, and input validation in a realistic scenario.
class BankAccount {
#owner;
#balance;
#transactions = [];
constructor(owner, initialDeposit = 0) {
if (typeof owner !== 'string' || owner.trim() === '') {
throw new TypeError('Owner must be a non-empty string');
}
this.#owner = owner.trim();
this.#balance = 0;
if (initialDeposit > 0) this.deposit(initialDeposit);
}
#record(type, amount) {
this.#transactions.push({
type, amount,
balance: this.#balance,
date: new Date().toISOString()
});
}
deposit(amount) {
if (typeof amount !== 'number' || amount <= 0) {
throw new RangeError('Deposit amount must be a positive number');
}
this.#balance += amount;
this.#record('deposit', amount);
return this;
}
withdraw(amount) {
if (typeof amount !== 'number' || amount <= 0) {
throw new RangeError('Withdrawal amount must be a positive number');
}
if (amount > this.#balance) {
throw new Error(`Insufficient funds. Balance: $${this.#balance.toFixed(2)}`);
}
this.#balance -= amount;
this.#record('withdrawal', amount);
return this;
}
transfer(amount, targetAccount) {
this.withdraw(amount);
targetAccount.deposit(amount);
return this;
}
get balance() { return this.#balance; }
get owner() { return this.#owner; }
get transactions() { return [...this.#transactions]; } // defensive copy
statement() {
const lines = this.#transactions.map(t =>
`${t.date.slice(0,10)} ${t.type.padEnd(12)} $${t.amount.toFixed(2).padStart(8)} balance: $${t.balance.toFixed(2)}`
);
return [`=== ${this.#owner}'s Statement ===`, ...lines].join('\n');
}
}
const alice = new BankAccount('Alice', 1000);
const bob = new BankAccount('Bob', 500);
alice.deposit(200).withdraw(50);
alice.transfer(300, bob);
console.log(alice.statement());
console.log(`Bob's balance: $${bob.balance}`);
=== Alice's Statement === 2026-06-04 deposit $1000.00 balance: $1000.00 2026-06-04 deposit $200.00 balance: $1200.00 2026-06-04 withdrawal $50.00 balance: $1150.00 2026-06-04 withdrawal $300.00 balance: $850.00 Bob's balance: $800
ποΈ Practical Exercise
Build a UserProfile class with encapsulation:
- Private fields:
#username,#email,#age. - Setters must validate: username 3β20 characters, email contains
@, age 0β120. - Provide a public
update(changes)method that accepts an object with optional keys and applies validated updates. - Add a private
#formatDate()method and a publictoJSON()that returns a plain object (no private data leaks).
π₯ Challenge Exercise
Extend BankAccount with a SavingsAccount subclass that adds a private #interestRate field and a public applyInterest() method. Override withdraw() so it enforces a minimum balance of $100 (the account must keep at least $100 at all times). Test edge cases.
Interview Questions
- What is encapsulation and why is it important in OOP?
- What is the difference between a private class field (#field) and a convention-based private property (_field)?
- How do getters and setters help enforce encapsulation?
- What is the WeakMap privacy pattern and when would you still use it?
- Can a subclass access private fields of its parent class?
π Summary
- Encapsulation hides internal state and exposes a controlled public interface.
- Private class fields (
#field) are enforced by the JavaScript engine β not just convention. - Private methods (
#method()) keep internal helpers invisible to consumers. - Getters and setters pair with private fields to provide validated property-like access.
- WeakMap-based privacy is a legacy pattern; prefer
#in all new code. - Subclasses cannot access parent private fields β they must use parent's public/protected accessors.
Frequently Asked Questions
No. Private fields (#field) are scoped strictly to the class body that declares them. A subclass cannot read or write a parent's private fields β it must use public or protected (getter) accessors instead. This is by design and ensures the parent's invariants are always enforced.
No. JSON.stringify only serialises enumerable own properties. Private fields are not own properties on the object β they are stored in an internal slot. However, if your toJSON() method intentionally includes a value derived from a private field, that will be serialised.
Yes. static #field = value declares a private static field that belongs to the class itself (not to instances) and is inaccessible outside the class body. Use it for class-level constants or counters that should never be exposed.