Encapsulation in OOP: Complete Guide with JavaScript Code Examples
Master encapsulation in JavaScript with practical examples. Learn private properties, getters/setters, closures, WeakMaps, and modern JavaScript encapsulation patterns for clean, maintainable code

The Day My Code Got Hacked (By Myself)
I'll never forget the moment I broke my own application. I was building a simple e-commerce cart:
const cart = {
items: [],
total: 0
};
cart.items.push({ name: "Laptop", price: 999 });
cart.total = 999; // Manually updating total
// Later in the code...
cart.items.push({ name: "Mouse", price: 29 });
// Forgot to update total! Now cart.total is wrong!
My app showed $999 for a cart containing $1,028 worth of items. Disaster.
My mentor looked at my code and said: "You broke the cardinal rule—never expose your data directly. Use encapsulation."
That one word changed everything. Today, I'll show you encapsulation—the most important OOP principle for writing bulletproof JavaScript code.
What Is Encapsulation?
Encapsulation is the practice of:
- Bundling data and methods that operate on that data together
- Hiding internal implementation details
- Restricting direct access to some of an object's components
- Exposing only what's necessary through a public interface
Real-world analogy: Think of a car. You use the steering wheel, pedals, and gear shift (public interface), but you don't directly manipulate the engine pistons or transmission gears (private implementation).
Why Encapsulation Matters
Without Encapsulation (The Bad Way)
class BankAccount {
constructor(owner, balance) {
this.owner = owner;
this.balance = balance; // Public! Anyone can modify!
}
}
const account = new BankAccount("Alice", 1000);
// Anyone can do this!
account.balance = 999999999; // 😱 Direct manipulation!
console.log(account.balance); // 999999999
// No validation, no security, no control
With Encapsulation (The Right Way)
class BankAccount {
#balance; // Private field (ES2022)
constructor(owner, initialBalance) {
this.owner = owner;
this.#balance = initialBalance;
}
deposit(amount) {
if (amount > 0) {
this.#balance += amount;
return true;
}
throw new Error("Invalid deposit amount");
}
withdraw(amount) {
if (amount > this.#balance) {
throw new Error("Insufficient funds");
}
this.#balance -= amount;
return true;
}
getBalance() {
return this.#balance; // Controlled access
}
}
const account = new BankAccount("Alice", 1000);
// This works (controlled)
account.deposit(500);
console.log(account.getBalance()); // 1500
// This doesn't work (protected!)
// account.#balance = 999999; // SyntaxError!
Benefits:
- ✅ Data protection: Balance can't be directly modified
- ✅ Validation: All changes go through validated methods
- ✅ Control: You decide what can be accessed
- ✅ Maintainability: Internal changes don't break external code
JavaScript Encapsulation Techniques
JavaScript has evolved significantly in its support for encapsulation. Let's explore all techniques from oldest to newest!
Technique 1: Convention-Based (Underscore Prefix)
Method: Use underscore prefix to indicate "private" (convention only, not enforced)
class User {
constructor(username, password) {
this.username = username;
this._password = password; // "Private" by convention
}
// Public method
validatePassword(inputPassword) {
return this._password === inputPassword;
}
// Public method to change password
changePassword(oldPassword, newPassword) {
if (!this.validatePassword(oldPassword)) {
throw new Error("Invalid old password");
}
if (newPassword.length < 8) {
throw new Error("Password must be at least 8 characters");
}
this._password = newPassword;
return true;
}
}
const user = new User("alice", "secret123");
// Public method (OK)
console.log(user.validatePassword("secret123")); // true
// But _password is still accessible (convention only!)
console.log(user._password); // "secret123" - Not truly private!
// Developer should know not to access _password directly
user.changePassword("secret123", "newSecret456"); // Proper way
Pros:
- ✅ Simple, works in all JavaScript versions
- ✅ Clear indication of "private" members
Cons:
- ❌ Not truly private (just a naming convention)
- ❌ No enforcement—developers can still access directly
Technique 2: Closures (True Privacy)
Method: Use closures to create truly private variables
function createBankAccount(owner, initialBalance) {
// Private variables (in closure scope)
let balance = initialBalance;
let transactionHistory = [];
// Private function
function logTransaction(type, amount) {
transactionHistory.push({
type,
amount,
date: new Date(),
balance: balance
});
}
// Public interface (returned object)
return {
owner, // Public property
deposit(amount) {
if (amount <= 0) {
throw new Error("Amount must be positive");
}
balance += amount;
logTransaction("deposit", amount);
console.log(`✅ Deposited $${amount}. New balance: $${balance}`);
return true;
},
withdraw(amount) {
if (amount <= 0) {
throw new Error("Amount must be positive");
}
if (amount > balance) {
throw new Error("Insufficient funds");
}
balance -= amount;
logTransaction("withdraw", amount);
console.log(`✅ Withdrew $${amount}. New balance: $${balance}`);
return true;
},
getBalance() {
return balance; // Controlled access
},
getTransactionHistory() {
// Return copy to prevent external modification
return [...transactionHistory];
}
};
}
// Create account
const account = createBankAccount("Bob", 1000);
// Public interface works
account.deposit(500); // ✅ Deposited $500. New balance: $1500
account.withdraw(200); // ✅ Withdrew $200. New balance: $1300
console.log(account.getBalance()); // 1300
// Private variables are truly inaccessible!
console.log(account.balance); // undefined
console.log(account.transactionHistory); // undefined
// Can't directly modify
// account.balance = 999999; // Does nothing!
console.log(account.getBalance()); // Still 1300
Pros:
- ✅ True privacy: Private variables are completely inaccessible
- ✅ Works in all JavaScript environments
- ✅ Clear separation of public/private
Cons:
- ❌ Not using
classsyntax - ❌ Can't use
instanceof - ❌ Each instance creates new function copies (memory overhead)
Technique 3: WeakMap (Class-Based Privacy)
Method: Use WeakMap to store private data for class instances
// Private data storage
const privateData = new WeakMap();
class User {
constructor(username, email, password) {
// Public properties
this.username = username;
this.email = email;
// Private data stored in WeakMap
privateData.set(this, {
password: password,
loginAttempts: 0,
lastLoginDate: null
});
}
// Public method
login(inputPassword) {
const data = privateData.get(this);
if (data.loginAttempts >= 5) {
throw new Error("Account locked due to too many failed attempts");
}
if (inputPassword === data.password) {
data.loginAttempts = 0;
data.lastLoginDate = new Date();
console.log(`✅ Welcome back, ${this.username}!`);
return true;
} else {
data.loginAttempts++;
console.log(`❌ Invalid password. Attempts: ${data.loginAttempts}/5`);
return false;
}
}
changePassword(oldPassword, newPassword) {
const data = privateData.get(this);
if (oldPassword !== data.password) {
throw new Error("Current password is incorrect");
}
if (newPassword.length < 8) {
throw new Error("New password must be at least 8 characters");
}
data.password = newPassword;
data.loginAttempts = 0; // Reset attempts
console.log("✅ Password changed successfully");
return true;
}
getLastLoginDate() {
const data = privateData.get(this);
return data.lastLoginDate;
}
getLoginAttempts() {
const data = privateData.get(this);
return data.loginAttempts;
}
}
const user = new User("alice", "alice@email.com", "secret123");
// Public properties accessible
console.log(user.username); // "alice"
console.log(user.email); // "alice@email.com"
// Private data inaccessible
console.log(user.password); // undefined
// Use public methods
user.login("wrongpass"); // ❌ Invalid password. Attempts: 1/5
user.login("secret123"); // ✅ Welcome back, alice!
console.log(user.getLastLoginDate()); // Date object
// Change password properly
user.changePassword("secret123", "newSecret456"); // ✅ Password changed successfully
// instanceof works!
console.log(user instanceof User); // true
Pros:
- ✅ True privacy with class syntax
- ✅
instanceofworks - ✅ Memory efficient (WeakMap allows garbage collection)
Cons:
- ❌ More complex syntax
- ❌ Private data is in separate WeakMap (not in class body)
Technique 4: Private Fields (Modern JavaScript - ES2022)
Method: Use # prefix for truly private class fields
class CreditCard {
// Public fields
cardHolder;
expiryDate;
// Private fields (ES2022)
#cardNumber;
#cvv;
#balance;
#pin;
constructor(cardHolder, cardNumber, cvv, expiryDate, pin) {
this.cardHolder = cardHolder;
this.expiryDate = expiryDate;
// Initialize private fields
this.#cardNumber = cardNumber;
this.#cvv = cvv;
this.#balance = 0;
this.#pin = pin;
}
// Private method
#validatePin(inputPin) {
return inputPin === this.#pin;
}
// Private method
#maskCardNumber() {
return `****-****-****-${this.#cardNumber.slice(-4)}`;
}
// Public method
getCardInfo() {
return {
holder: this.cardHolder,
number: this.#maskCardNumber(),
expiry: this.expiryDate
};
}
// Public method
deposit(amount, pin) {
if (!this.#validatePin(pin)) {
throw new Error("Invalid PIN");
}
if (amount <= 0) {
throw new Error("Amount must be positive");
}
this.#balance += amount;
console.log(`✅ Deposited $${amount}. Balance: $${this.#balance}`);
return true;
}
// Public method
withdraw(amount, pin) {
if (!this.#validatePin(pin)) {
throw new Error("Invalid PIN");
}
if (amount > this.#balance) {
throw new Error("Insufficient funds");
}
this.#balance -= amount;
console.log(`✅ Withdrew $${amount}. Balance: $${this.#balance}`);
return true;
}
// Public method
checkBalance(pin) {
if (!this.#validatePin(pin)) {
throw new Error("Invalid PIN");
}
return this.#balance;
}
// Public method
changePin(oldPin, newPin) {
if (!this.#validatePin(oldPin)) {
throw new Error("Invalid current PIN");
}
if (newPin.length !== 4 || isNaN(newPin)) {
throw new Error("PIN must be 4 digits");
}
this.#pin = newPin;
console.log("✅ PIN changed successfully");
return true;
}
}
const card = new CreditCard(
"Alice Johnson",
"1234567890123456",
"123",
"12/26",
"1234"
);
// Public data accessible
console.log(card.cardHolder); // "Alice Johnson"
console.log(card.getCardInfo());
// { holder: "Alice Johnson", number: "****-****-****-3456", expiry: "12/26" }
// Private fields are truly inaccessible!
// console.log(card.#cardNumber); // SyntaxError!
// console.log(card.#cvv); // SyntaxError!
// console.log(card.#balance); // SyntaxError!
// Use public methods with validation
card.deposit(1000, "1234"); // ✅ Deposited $1000. Balance: $1000
card.withdraw(200, "1234"); // ✅ Withdrew $200. Balance: $800
// Wrong PIN
try {
card.withdraw(100, "9999");
} catch (error) {
console.log(error.message); // "Invalid PIN"
}
console.log(card.checkBalance("1234")); // 800
// Change PIN
card.changePin("1234", "5678"); // ✅ PIN changed successfully
Pros:
- ✅ True privacy: Enforced by JavaScript engine
- ✅ Clean class syntax
- ✅ Private methods and fields
- ✅
instanceofworks - ✅ Standard JavaScript (ES2022)
Cons:
- ❌ Requires modern JavaScript support
- ❌ Can't access private fields in subclasses
Encapsulation with Getters and Setters
Getters and setters provide controlled access to properties with validation.
Basic Getters and Setters
class Temperature {
#celsius;
constructor(celsius) {
this.celsius = celsius; // Uses setter
}
// Getter
get celsius() {
return this.#celsius;
}
// Setter with validation
set celsius(value) {
if (value < -273.15) {
throw new Error("Temperature below absolute zero!");
}
this.#celsius = value;
}
// Computed property (getter only)
get fahrenheit() {
return (this.#celsius * 9/5) + 32;
}
// Computed property (setter)
set fahrenheit(value) {
this.celsius = (value - 32) * 5/9; // Uses celsius setter
}
// Computed property (getter only)
get kelvin() {
return this.#celsius + 273.15;
}
}
const temp = new Temperature(25);
// Access like properties (but uses getters/setters)
console.log(temp.celsius); // 25
console.log(temp.fahrenheit); // 77
console.log(temp.kelvin); // 298.15
// Set value (validation runs automatically)
temp.celsius = 30;
console.log(temp.celsius); // 30
console.log(temp.fahrenheit); // 86
// Set fahrenheit (converts to celsius)
temp.fahrenheit = 100;
console.log(temp.celsius); // 37.77...
console.log(temp.fahrenheit); // 100
// Validation prevents invalid values
try {
temp.celsius = -300; // Below absolute zero!
} catch (error) {
console.log(error.message); // "Temperature below absolute zero!"
}
Real-World Example: User Profile with Validation
class UserProfile {
#email;
#age;
#phoneNumber;
constructor(name, email, age) {
this.name = name;
this.email = email; // Uses setter
this.age = age; // Uses setter
}
// Email getter/setter
get email() {
return this.#email;
}
set email(value) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
throw new Error("Invalid email format");
}
this.#email = value;
}
// Age getter/setter
get age() {
return this.#age;
}
set age(value) {
if (value < 13) {
throw new Error("User must be at least 13 years old");
}
if (value > 120) {
throw new Error("Invalid age");
}
this.#age = value;
}
// Phone getter/setter
get phoneNumber() {
return this.#phoneNumber;
}
set phoneNumber(value) {
// Remove all non-digits
const digits = value.replace(/\D/g, '');
if (digits.length !== 10) {
throw new Error("Phone number must be 10 digits");
}
// Store in formatted version
this.#phoneNumber = `(${digits.slice(0,3)}) ${digits.slice(3,6)}-${digits.slice(6)}`;
}
// Computed property (read-only)
get isAdult() {
return this.#age >= 18;
}
getProfileSummary() {
return {
name: this.name,
email: this.#email,
age: this.#age,
phone: this.#phoneNumber,
adult: this.isAdult
};
}
}
// Create profile
const user = new UserProfile("Alice", "alice@email.com", 25);
// Getters work like properties
console.log(user.email); // "alice@email.com"
console.log(user.age); // 25
console.log(user.isAdult); // true
// Setters validate automatically
user.phoneNumber = "5551234567";
console.log(user.phoneNumber); // "(555) 123-4567"
// Invalid email
try {
user.email = "invalid-email";
} catch (error) {
console.log(error.message); // "Invalid email format"
}
// Invalid age
try {
user.age = 10;
} catch (error) {
console.log(error.message); // "User must be at least 13 years old"
}
console.log(user.getProfileSummary());
// {
// name: "Alice",
// email: "alice@email.com",
// age: 25,
// phone: "(555) 123-4567",
// adult: true
// }
Real-World Example: E-Commerce Shopping Cart
Let's build a complete shopping cart with proper encapsulation:
class ShoppingCart {
#items = [];
#discountRate = 0;
#taxRate = 0.08; // 8% tax
constructor(userId) {
this.userId = userId;
this.createdAt = new Date();
}
// Private method: Calculate item subtotal
#calculateItemSubtotal(item) {
return item.price * item.quantity;
}
// Private method: Find item by ID
#findItemIndex(productId) {
return this.#items.findIndex(item => item.productId === productId);
}
// Public method: Add item
addItem(productId, name, price, quantity = 1) {
if (price <= 0) {
throw new Error("Price must be positive");
}
if (quantity <= 0) {
throw new Error("Quantity must be positive");
}
const existingIndex = this.#findItemIndex(productId);
if (existingIndex !== -1) {
// Item exists, update quantity
this.#items[existingIndex].quantity += quantity;
console.log(`✅ Updated ${name} quantity to ${this.#items[existingIndex].quantity}`);
} else {
// New item
this.#items.push({ productId, name, price, quantity });
console.log(`✅ Added ${name} to cart`);
}
}
// Public method: Remove item
removeItem(productId) {
const index = this.#findItemIndex(productId);
if (index === -1) {
throw new Error("Item not found in cart");
}
const item = this.#items[index];
this.#items.splice(index, 1);
console.log(`✅ Removed ${item.name} from cart`);
}
// Public method: Update quantity
updateQuantity(productId, newQuantity) {
if (newQuantity <= 0) {
throw new Error("Quantity must be positive");
}
const index = this.#findItemIndex(productId);
if (index === -1) {
throw new Error("Item not found in cart");
}
this.#items[index].quantity = newQuantity;
console.log(`✅ Updated quantity to ${newQuantity}`);
}
// Public method: Apply discount
applyDiscount(discountCode) {
// Simulate discount code validation
const validCodes = {
"SAVE10": 0.10,
"SAVE20": 0.20,
"SAVE50": 0.50
};
if (validCodes[discountCode]) {
this.#discountRate = validCodes[discountCode];
console.log(`✅ Applied ${discountCode}: ${this.#discountRate * 100}% off`);
return true;
} else {
throw new Error("Invalid discount code");
}
}
// Getters for calculated values
get subtotal() {
return this.#items.reduce((sum, item) => {
return sum + this.#calculateItemSubtotal(item);
}, 0);
}
get discountAmount() {
return this.subtotal * this.#discountRate;
}
get subtotalAfterDiscount() {
return this.subtotal - this.discountAmount;
}
get tax() {
return this.subtotalAfterDiscount * this.#taxRate;
}
get total() {
return this.subtotalAfterDiscount + this.tax;
}
get itemCount() {
return this.#items.reduce((sum, item) => sum + item.quantity, 0);
}
// Public method: Get cart summary
getSummary() {
return {
items: this.#items.map(item => ({
name: item.name,
price: item.price,
quantity: item.quantity,
subtotal: this.#calculateItemSubtotal(item)
})),
subtotal: this.subtotal.toFixed(2),
discount: this.discountAmount.toFixed(2),
tax: this.tax.toFixed(2),
total: this.total.toFixed(2),
itemCount: this.itemCount
};
}
// Public method: Clear cart
clear() {
this.#items = [];
this.#discountRate = 0;
console.log("✅ Cart cleared");
}
}
// Test the shopping cart
const cart = new ShoppingCart("user123");
// Add items
cart.addItem("P001", "Laptop", 999.99, 1);
cart.addItem("P002", "Mouse", 29.99, 2);
cart.addItem("P003", "Keyboard", 79.99, 1);
// Try to add same laptop (updates quantity)
cart.addItem("P001", "Laptop", 999.99, 1);
// View summary
console.log("\n=== Cart Summary (Before Discount) ===");
let summary = cart.getSummary();
console.log(`Items: ${summary.itemCount}`);
console.log(`Subtotal: $${summary.subtotal}`);
console.log(`Tax: $${summary.tax}`);
console.log(`Total: $${summary.total}`);
// Apply discount
cart.applyDiscount("SAVE20");
// View summary after discount
console.log("\n=== Cart Summary (After Discount) ===");
summary = cart.getSummary();
console.log(`Subtotal: $${summary.subtotal}`);
console.log(`Discount: -$${summary.discount}`);
console.log(`Tax: $${summary.tax}`);
console.log(`Total: $${summary.total}`);
// Private data is inaccessible
console.log(cart.items); // undefined
console.log(cart.discountRate); // undefined
// Can only access through public interface
console.log(`Total: $${cart.total.toFixed(2)}`);
Best Practices for Encapsulation
1. Always Validate in Setters
class Product {
#price;
set price(value) {
if (value < 0) {
throw new Error("Price cannot be negative");
}
if (!Number.isFinite(value)) {
throw new Error("Price must be a valid number");
}
this.#price = value;
}
get price() {
return this.#price;
}
}
2. Return Copies, Not References
class DataStore {
#data = [];
getData() {
// Return copy to prevent external modification
return [...this.#data];
}
addData(item) {
// Store copy to prevent external modification
this.#data.push({ ...item });
}
}
3. Use Private Methods for Internal Logic
class UserService {
#validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
#hashPassword(password) {
// Private hashing logic
return `hashed_${password}`;
}
createUser(email, password) {
if (!this.#validateEmail(email)) {
throw new Error("Invalid email");
}
const hashedPassword = this.#hashPassword(password);
// Create user...
}
}
4. Provide Meaningful Public Interface
// Good: Clear, descriptive public methods
class Order {
#status;
markAsShipped() { /* ... */ }
markAsDelivered() { /* ... */ }
cancelOrder() { /* ... */ }
}
// Bad: Exposing internal details
class Order {
status; // Public! Can be set to invalid values
}
Conclusion: Encapsulation Is Your Safety Net
Encapsulation isn't about being secretive—it's about protecting your data and controlling how it's used.
Key takeaways:
- ✅ Use private fields (
#field) for truly private data - ✅ Provide controlled access through public methods
- ✅ Validate all inputs in setters and methods
- ✅ Hide implementation details, expose only interfaces
- ✅ Return copies of internal data, not references
Which technique to use:
- Modern projects: Private fields (
#field) - Need wide browser support: Closures or WeakMap
- Convention-based: Underscore prefix (if team agrees)
Start encapsulating your code today. Your future self (and your team) will thank you!
If you're building robust JavaScript applications with proper encapsulation, I'd love to see what you're working on! Connect with me on Twitter or LinkedIn!
Support My Work
If this guide helped you master encapsulation in JavaScript and write more secure, maintainable code, I'd really appreciate your support! Creating comprehensive, practical content takes significant time and effort. Your support helps me continue sharing knowledge and creating more helpful resources.
☕ Buy me a coffee - Every contribution means the world to me!
Cover image by Kira auf der Heide on Unsplash