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.
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:
// 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:
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 enforcementVerdict: 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:
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 hiddenVerdict: 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:
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 objectVerdict: 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():
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 leaksVerdict: 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 #:
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 classFor 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:
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); // 0Comparison Table
| Technique | Truly Private | Works with Classes | Memory Efficient | Requires ES2022 |
|---|---|---|---|---|
Underscore _ convention | No | Yes | Yes | No |
| Closure (factory) | Yes | No (factory pattern) | No (per-instance methods) | No |
| WeakMap | Yes | Yes | Yes | No |
| Symbol key | No (semi) | Yes | Yes | No |
Private # field | Yes | Yes | Yes | Yes (ES2022) |
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
Frequently Asked Questions
Is the underscore convention still used in modern projects?
Do WeakMap private properties work with JSON.stringify?
When should I use getters and setters vs regular methods?
Can private class fields be accessed in subclasses?
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.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.