Ad – 728Γ—90
πŸ—οΈ OOP

JavaScript Encapsulation – Data Hiding with Private Fields

Encapsulation is the OOP principle of bundling data and the methods that operate on it together, while restricting direct external access to internal state. JavaScript ES2022 brought true private class fields and methods (the # prefix), replacing older workarounds like WeakMaps and symbol keys. This lesson teaches you how to design classes that expose a clean public API while protecting their internal data from accidental mutation.

⏱️ 19 min read 🎯 Intermediate πŸ“… Updated 2026

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).
ℹ️
Why does encapsulation matter?

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.

JavaScript
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);
β–Ά Output
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.

JavaScript
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
β–Ά Output
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.

FeaturePublic fieldPrivate field + accessor
Direct mutationYes – no controlNo – only via setter
Validation on writeManual, easily bypassedEnforced in setter
Computed readNoYes – getter can compute
Read-onlyWith Object.freeze onlyYes – omit setter entirely
Browser supportAll modern browsersES2022 / Node 12+
πŸ’‘
Naming convention for backing fields

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.

JavaScript
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
β–Ά Output
11
undefined
⚠️
Prefer # over WeakMap for new code

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.

JavaScript
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}`);
β–Ά Output
=== 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
Ad – 336Γ—280

πŸ‹οΈ 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 public toJSON() 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

Can a subclass access parent private fields? +

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.

Does JSON.stringify leak private field values? +

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.

Are private static fields possible? +

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.