Encapsulation in JavaScript: Complete Tutorial

A complete guide to encapsulation in JavaScript. Learn convention-based encapsulation with underscore prefixes, WeakMap-based private state, Symbol-based hiding, ES2022 private class fields, getters and setters for controlled access, and when each approach makes sense.

JavaScriptintermediate
12 min read

Encapsulation is the practice of restricting direct access to an object's internal state and exposing only a controlled public interface. In languages like Java or C#, the private keyword enforces this at the language level. JavaScript's approach evolved over time: from pure convention to language-level enforcement with ES2022 private class fields (#). This tutorial walks through every encapsulation technique — and when to use each.

Why Encapsulation Matters

Without encapsulation, any code can read or modify an object's internal data:

javascriptjavascript
// No encapsulation — internal state fully exposed
class BankAccount {
  constructor(balance) {
    this.balance = balance; // Public — anyone can set this
  }
}
 
const account = new BankAccount(1000);
account.balance = -99999; // No validation — corruption!

Encapsulation prevents invalid state mutations, hides implementation details (so internals can change without breaking callers), and creates clear boundaries between what is public API and what is internal.

Approach 1: Underscore Convention

The oldest pattern — prefix "private" properties with a single underscore as a signal to other developers:

javascriptjavascript
class Person {
  constructor(name, age) {
    this._name = name; // Convention: treat as private
    this._age = age;
  }
 
  getName() { return this._name; }
  getAge()  { return this._age; }
 
  setAge(age) {
    if (age < 0 || age > 150) throw new RangeError("Invalid age");
    this._age = age;
  }
}
 
const p = new Person("Alice", 30);
console.log(p.getName()); // "Alice"
p.setAge(31);
// p._age is still accessible — underscore is ONLY a convention, not enforcement

Verdict: Zero runtime cost, universally understood, but provides no actual protection. Used in pre-ES2022 codebases you will encounter frequently.

Approach 2: Closure-Based Private State

Leverage closures so that properties never exist on the object at all:

javascriptjavascript
function createCounter(initial = 0) {
  let _count = initial; // Truly private — not on the object
 
  return {
    increment() { _count += 1; },
    decrement() { _count -= 1; },
    reset()     { _count = 0; },
    get value() { return _count; },
  };
}
 
const counter = createCounter(10);
counter.increment();
counter.increment();
console.log(counter.value); // 12
console.log(counter._count); // undefined — truly hidden

Verdict: Genuinely private state. Drawback: each object gets its own copy of all methods (no prototype sharing), and instanceof / inheritance do not work naturally.

Approach 3: WeakMap-Based Private State

Use a WeakMap outside the class to associate instances with their private data:

javascriptjavascript
const _balance = new WeakMap();
 
class BankAccount {
  constructor(initialBalance) {
    if (initialBalance < 0) throw new RangeError("Balance cannot be negative");
    _balance.set(this, initialBalance);
  }
 
  deposit(amount) {
    if (amount <= 0) throw new RangeError("Deposit must be positive");
    _balance.set(this, _balance.get(this) + amount);
    return this;
  }
 
  withdraw(amount) {
    const current = _balance.get(this);
    if (amount > current) throw new Error("Insufficient funds");
    _balance.set(this, current - amount);
    return this;
  }
 
  get balance() {
    return _balance.get(this);
  }
}
 
const acc = new BankAccount(500);
acc.deposit(200).withdraw(100);
console.log(acc.balance); // 600
console.log(acc._balance); // undefined — not on the object

Verdict: Genuinely private + prototype methods (shared across instances). Compatible with inheritance. Slightly verbose. Most viable ES5/ES6 approach for classes.

Approach 4: Symbol Keys

Symbols are unique and not returned by Object.keys() or JSON.stringify():

javascriptjavascript
const _id = Symbol("id");
const _created = Symbol("created");
 
class Record {
  constructor(data) {
    this[_id] = Math.random().toString(36).slice(2);
    this[_created] = new Date();
    this.data = data;
  }
 
  getId()      { return this[_id]; }
  getCreatedAt() { return this[_created]; }
}
 
const r = new Record({ name: "test" });
console.log(r.getId());         // "k3m9xph2" (random)
console.log(Object.keys(r));    // ["data"] — symbols excluded
console.log(JSON.stringify(r)); // {"data":{"name":"test"}} — symbols excluded
 
// Still accessible with the symbol reference:
console.log(r[_id]); // "k3m9xph2" — not truly private if symbol leaks

Verdict: Semi-private. Properties hide from common enumeration, but anyone with the Symbol reference can access them. Better than underscore, not as strong as WeakMap or private fields.

Approach 5: ES2022 Private Class Fields (#)

The modern, language-enforced solution — declared with #:

javascriptjavascript
class BankAccount {
  #balance;
  #transactionLog = [];
 
  constructor(initialBalance) {
    if (initialBalance < 0) throw new RangeError("Balance cannot be negative");
    this.#balance = initialBalance;
  }
 
  deposit(amount) {
    if (amount <= 0) throw new RangeError("Must be positive");
    this.#balance += amount;
    this.#log("deposit", amount);
    return this;
  }
 
  withdraw(amount) {
    if (amount > this.#balance) throw new Error("Insufficient funds");
    this.#balance -= amount;
    this.#log("withdraw", amount);
    return this;
  }
 
  #log(type, amount) { // Private method
    this.#transactionLog.push({ type, amount, balance: this.#balance });
  }
 
  get balance() { return this.#balance; }
  get history()  { return [...this.#transactionLog]; }
}
 
const acc = new BankAccount(1000);
acc.deposit(500).withdraw(200);
console.log(acc.balance);   // 1300
console.log(acc.#balance);  // SyntaxError: Private field '#balance' must be declared in an enclosing class

For a complete reference on the # syntax, see creating private class fields in modern JS.

Getters and Setters for Controlled Access

Regardless of the backing storage technique, getters and setters provide a controlled public interface:

javascriptjavascript
class Temperature {
  #celsius;
 
  constructor(celsius) {
    this.celsius = celsius; // Uses setter for validation
  }
 
  get celsius() { return this.#celsius; }
  set celsius(c) {
    if (typeof c !== "number") throw new TypeError("Must be a number");
    if (c < -273.15) throw new RangeError("Below absolute zero");
    this.#celsius = c;
  }
 
  get fahrenheit() { return this.#celsius * 9/5 + 32; }
  set fahrenheit(f) { this.celsius = (f - 32) * 5/9; }
 
  get kelvin() { return this.#celsius + 273.15; }
}
 
const t = new Temperature(100);
console.log(t.fahrenheit); // 212
t.fahrenheit = 32;
console.log(t.celsius);    // 0

Comparison Table

TechniqueTruly PrivateWorks with ClassesMemory EfficientRequires ES2022
Underscore _ conventionNoYesYesNo
Closure (factory)YesNo (factory pattern)No (per-instance methods)No
WeakMapYesYesYesNo
Symbol keyNo (semi)YesYesNo
Private # fieldYesYesYesYes (ES2022)
Rune AI

Rune AI

Key Insights

  • Encapsulation guards object invariants: Hiding internal state behind a public API prevents invalid mutations and decouples callers from implementation details
  • Underscore is convention, not enforcement: _property is still fully accessible; it is only a social contract between developers
  • WeakMaps provide pre-ES2022 true privacy: Store instance-private data in a WeakMap keyed by the instance — invisible to enumeration, not on the object, and memory-safe (GC collects entries when instances are collected)
  • Private # fields are language-enforced: Accessing #field outside the declaring class body throws a SyntaxError — the strictest form of encapsulation available in JavaScript
  • Getters and setters complete the pattern: They provide a clean property-like interface while executing validation, computation, or side effects behind the scenes
RunePowered by Rune AI

Frequently Asked Questions

Is the underscore convention still used in modern projects?

Yes, extensively, especially in pre-2022 codebases and in quick internal utilities. If you see `_name` or `_value` in JavaScript code, the author signals "treat this as private" even though the language doesn't enforce it.

Do WeakMap private properties work with JSON.stringify?

WeakMap-stored values are not properties on the object, so they are completely invisible to `JSON.stringify`. The object serializes only its enumerable own properties.

When should I use getters and setters vs regular methods?

Use a getter/setter when the "property" value is conceptually a data property (it represents state) but needs computation or validation. Use regular methods (`getX()`/`setX()`) when the operation has complex side effects or multiple parameters. Getters/setters that do too much computation or throw frequently can be surprising.

Can private class fields be accessed in subclasses?

No. Private class fields are scoped to the class body that declares them. Subclasses cannot access parent private fields directly — this is intentional. The parent class must expose public or protected-style getter methods for subclasses to use.

Conclusion

Encapsulation in JavaScript evolved from the humble underscore convention all the way to enforced private class fields. For modern code targeting current Node.js or browsers, ES2022 # fields are the cleanest option — they offer true language-level privacy, work with prototype methods, integrate with class inheritance, and eliminate the verbosity of WeakMaps. Pair private fields with getter/setter methods to build a controlled public API that guards invariants and hides implementation details.