JavaScript Encapsulation
Encapsulation is the practice of bundling data together with the code that operates on it, while hiding the internal details from the outside world. In JavaScript this means keeping an object’s state private and only exposing a controlled, intentional interface (methods, getters, setters) for interacting with it. Encapsulation matters because it prevents other parts of your program from reaching in and corrupting an object’s internal state directly, which makes code more predictable, easier to debug, and safer to refactor.
Overview: How Encapsulation Works in JavaScript
Unlike languages such as Java or C++, JavaScript did not originally have a built-in private keyword for object properties. For most of its history, developers achieved encapsulation through closures: a function defined inside another function keeps access to that outer function’s variables even after the outer function has returned. Any variable declared with let or const inside a factory function is invisible from outside that function’s scope — there is no syntax that can reach it. This is real, unbreakable privacy, and it is still widely used today, especially in factory functions and module patterns.
Since ES2022, JavaScript classes support true private fields and methods using a # prefix (e.g. #balance). These are enforced by the JavaScript engine itself, not just a naming convention like the old _balance trick. Attempting to access instance.#balance from outside the class body is not just discouraged — it is a SyntaxError, because the parser only allows the #name syntax to appear within the lexical body of the class that declared it. Internally, each class instance gets a hidden, non-enumerable slot for every private field it declares; these slots do not show up in Object.keys(), JSON.stringify(), for...in loops, or the console’s object inspector (aside from Node’s special debug formatting).
Alongside privacy mechanisms, JavaScript gives you accessors — get and set methods that look like plain properties from the outside but run custom code underneath. This lets you validate input on assignment, compute derived values on read, and change your internal representation later without breaking any code that consumes your object. This combination — hidden state plus a curated public interface — is the essence of encapsulation.
Syntax
class ClassName {
#privateField; // private instance field
#privateWithDefault = 0; // private field with a default value
constructor(value) {
this.#privateField = value;
}
#privateMethod() { // private instance method
// internal-only logic
}
get propertyName() { // public getter
return this.#privateField;
}
set propertyName(newValue) { // public setter
// validate, then assign
this.#privateField = newValue;
}
}
#privateField— must be declared once in the class body (with or without an initializer) before it can be used anywhere in the class, including the constructor.#privateMethod()— behaves like a normal method but is unreachable from outside the class; useful for internal helper logic you don’t want to expose.get/set— define computed, validated access that callers use with normal property syntax (obj.propertyName), not function-call syntax.- Closures (an alternative, pre-ES2022 pattern) achieve the same hiding by declaring variables inside a factory function and returning only the functions that need to see them.
Examples
Example 1: Encapsulation with closures
function createBankAccount(initialBalance) {
let balance = initialBalance;
function deposit(amount) {
if (amount <= 0) throw new Error("Deposit must be positive");
balance += amount;
return balance;
}
function withdraw(amount) {
if (amount > balance) throw new Error("Insufficient funds");
balance -= amount;
return balance;
}
function getBalance() {
return balance;
}
return { deposit, withdraw, getBalance };
}
const account = createBankAccount(100);
console.log(account.getBalance());
account.deposit(50);
console.log(account.getBalance());
account.withdraw(30);
console.log(account.getBalance());
console.log(account.balance);
Output:
100
150
120
undefined
The balance variable lives only inside the scope of createBankAccount. The returned object exposes just three functions that can read or modify it through controlled logic. There is no property called balance on the returned object at all — account.balance is undefined because the real data is trapped in the closure, completely inaccessible except through deposit, withdraw, and getBalance.
Example 2: Encapsulation with private class fields
class BankAccount {
#balance;
#owner;
constructor(owner, initialBalance = 0) {
this.#owner = owner;
this.#balance = initialBalance;
}
deposit(amount) {
this.#validateAmount(amount);
this.#balance += amount;
return this.#balance;
}
withdraw(amount) {
this.#validateAmount(amount);
if (amount > this.#balance) {
throw new Error("Insufficient funds");
}
this.#balance -= amount;
return this.#balance;
}
#validateAmount(amount) {
if (typeof amount !== "number" || amount <= 0) {
throw new Error("Amount must be a positive number");
}
}
get balance() {
return this.#balance;
}
get owner() {
return this.#owner;
}
}
const acc = new BankAccount("Alice", 200);
console.log(acc.balance);
acc.deposit(100);
console.log(acc.balance);
acc.withdraw(50);
console.log(`${acc.owner}'s balance is ${acc.balance}`);
Output:
200
300
Alice's balance is 250
Here #balance and #owner are true private fields, and #validateAmount is a private helper method reused by both deposit and withdraw so the validation rule lives in exactly one place. The public surface of the class is deliberately small: deposit(), withdraw(), and two read-only getters. Nothing outside the class can bypass validation and mutate #balance directly.
Example 3: Getters and setters for validated, derived properties
class Temperature {
#celsius;
constructor(celsius) {
this.#celsius = celsius;
}
get celsius() {
return this.#celsius;
}
set celsius(value) {
if (typeof value !== "number") {
throw new TypeError("Temperature must be a number");
}
this.#celsius = value;
}
get fahrenheit() {
return this.#celsius * 9 / 5 + 32;
}
set fahrenheit(value) {
this.celsius = (value - 32) * 5 / 9;
}
toString() {
return `${this.#celsius}\u00b0C`;
}
}
const temp = new Temperature(25);
console.log(temp.fahrenheit);
temp.fahrenheit = 98.6;
console.log(temp.celsius);
console.log(`${temp}`);
Output:
77
37
37°C
Callers interact with temp.fahrenheit and temp.celsius as if they were plain fields, but every read and write actually runs through a method. The setter for fahrenheit converts the value and reuses the celsius setter, so validation logic stays in one place. This is encapsulation doing real work: the object presents a simple, unit-agnostic interface while hiding the conversion math and the single source of truth (#celsius) behind it.
Under the Hood
When the JavaScript engine parses a class body containing #field declarations, it registers those private names in a way that is scoped to the class definition itself, not to any single instance. Each time new is used to construct an instance, the engine allocates hidden internal slots on that instance for every declared private field. These slots are implemented similarly to a specialized, engine-managed WeakMap keyed by the instance, though you never see that implementation directly — you only see the #name syntax.
Because private names are a syntactic feature (not just a property with a special string key), the parser rejects obj.#field anywhere outside the lexical body of the class that declared #field. This is stricter than the old convention of naming things _field with a leading underscore, which is only a social convention — nothing stops external code from reading or writing obj._field. True private fields make that impossible at the language level.
For the closure pattern, the mechanism is different but the guarantee is just as strong: JavaScript functions form a scope chain, and inner functions retain a live reference to their enclosing scope's variables for as long as the inner function exists (this is what keeps balance alive in Example 1 long after createBankAccount has returned). Since only the functions declared inside that scope can see those variables, there is no syntax at all — not even a special one — that lets outside code reach in.
Common Mistakes
Mistake 1: Using a private field before declaring it. Private fields must be declared directly in the class body; you cannot dynamically create one just by assigning to this.#field in a method the way you can with a normal property. Writing:
class Broken { constructor() { this.#count = 0; } } without a corresponding #count; field declaration in the class body throws a SyntaxError, because the parser has never seen #count declared anywhere in that class.
The fix is simple — always declare the field first:
class Counter {
#count;
constructor() {
this.#count = 0;
}
increment() {
this.#count += 1;
return this.#count;
}
}
const c = new Counter();
console.log(c.increment());
console.log(c.increment());
Output:
1
2
Mistake 2: Returning a direct reference to internal mutable state. Encapsulation is broken the moment you hand out a reference to an internal array or object without protection, because the caller can mutate it directly and bypass all your validation:
class Playlist {
#songs = [];
addSong(title) {
this.#songs.push(title);
}
getSongs() {
return this.#songs; // leaks the real internal array
}
}
const pl = new Playlist();
pl.addSong("Song A");
const songs = pl.getSongs();
songs.push("Hacked Song"); // mutates internal state from outside!
console.log(pl.getSongs());
Output:
[ 'Song A', 'Hacked Song' ]
The fix is to return a copy, not the original reference, so external code can only read the data, never mutate the source of truth:
class SafePlaylist {
#songs = [];
addSong(title) {
this.#songs.push(title);
}
getSongs() {
return [...this.#songs]; // return a copy
}
}
const pl2 = new SafePlaylist();
pl2.addSong("Song A");
const songsCopy = pl2.getSongs();
songsCopy.push("Hacked Song");
console.log(pl2.getSongs());
Output:
[ 'Song A' ]
Best Practices
- Default to making fields private (
#field) and only expose what callers genuinely need through getters, setters, or methods. - Put validation logic in setters or dedicated private methods so invalid state can never be created, not even accidentally from inside the class.
- Never return direct references to private arrays or objects; return a copy (spread syntax,
slice(), orstructuredClone()) or a read-only view. - Prefer private class fields (
#field) over the legacy underscore convention (_field) for new code — the underscore is only a hint, while#is enforced by the engine. - Use closures for simple, one-off encapsulated objects (factory functions) when you don't need inheritance or
instanceofchecks; use classes with#fields when you need a reusable, inheritable blueprint. - Keep the public API of a class small and intentional — every public method or getter is a promise to consumers that you'll maintain it, so don't expose internals "just in case."
- Use getters for computed/derived values instead of storing and manually syncing redundant fields.
Practice Exercises
Exercise 1: Write a Stack class using private fields that stores items in a private array and exposes only push(item), pop(), peek(), and a size getter. Make sure the underlying array can never be accessed or mutated from outside the class.
Exercise 2: Refactor this closure-based counter into a class using private fields and a private method: function createCounter(step) { let count = 0; return { next: () => { count += step; return count; } }; }. Add a public reset() method and a private #validateStep() method that throws if step is not a positive number.
Exercise 3: Create a User class with a private #password field. Add a setPassword(newPassword) method that throws an error if the password is shorter than 8 characters, and a checkPassword(attempt) method that returns true or false without ever exposing the real password value through any getter.
Summary
- Encapsulation hides an object's internal state and exposes only a controlled, intentional public interface.
- Closures provide encapsulation by scoping variables inside a factory function so only inner functions can access them.
- Private class fields (
#field) and private methods (#method()), available since ES2022, are enforced by the JavaScript engine and are inaccessible outside the declaring class's lexical body. - Getters and setters let you expose a clean, property-like interface while running validation or computed logic underneath.
- Never leak direct references to private mutable data — always return copies to preserve the integrity of your internal state.
- Prefer
#private fields over the old underscore-prefix convention, since the convention is easily bypassed while#is a hard language guarantee.
