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

📅 Published: February 20, 2025 ✏️ Updated: March 10, 2025 By Ojaswi Athghara
#encapsulation #web-development #oop #private #getters

Encapsulation in OOP: Complete Guide with JavaScript Code Examples

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:

  1. Bundling data and methods that operate on that data together
  2. Hiding internal implementation details
  3. Restricting direct access to some of an object's components
  4. 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 class syntax
  • ❌ 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
  • instanceof works
  • ✅ 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
  • instanceof works
  • ✅ 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

Related Blogs

Ojaswi Athghara

SDE, 4+ Years

© ojaswiat.com 2025-2027