Skip to content

Encapsulation: Protecting Data and Hiding Implementation Details

Why We Need Encapsulation

In daily life, when we use various devices, we rarely need to understand their internal working principles. When using a smartphone, we only care about simple operations like touching the screen and clicking icons; we don't need to know how the processor executes instructions or how memory is allocated. This idea of "hiding complexity and exposing simple interfaces" is the core concept of encapsulation.

Encapsulation is one of the basic principles of object-oriented programming with two main goals:

  1. Data Hiding: Hide the internal state of objects to prevent direct external access and modification
  2. Interface Simplification: Only expose necessary public methods, hiding complex implementation details

Let's understand the importance of encapsulation through a practical example:

javascript
// Code without encapsulation - problem example
const userAccount = {
  balance: 1000,
  overdraftLimit: 500,
};

// Any code can directly modify the balance
userAccount.balance = 999999; // 😱 No validation!
userAccount.overdraftLimit = -1000; // 😱 Invalid value!

// Can even add strange properties
userAccount.secretMoney = 1000000; // 😱 Data structure is corrupted!

This approach has serious problems: there's no protection mechanism, data can be arbitrarily modified, easily leading to error states.

javascript
// Code with encapsulation - solution
class UserAccount {
  // Private fields - inaccessible from outside
  #balance;
  #overdraftLimit;

  constructor(initialBalance, overdraftLimit = 0) {
    if (initialBalance < 0) {
      throw new Error("Initial balance cannot be negative");
    }
    if (overdraftLimit < 0) {
      throw new Error("Overdraft limit cannot be negative");
    }

    this.#balance = initialBalance;
    this.#overdraftLimit = overdraftLimit;
  }

  // Public interface - controlled access
  deposit(amount) {
    if (amount <= 0) {
      throw new Error("Deposit amount must be greater than 0");
    }

    this.#balance += amount;
    this.#recordTransaction("DEPOSIT", amount, "Deposit");

    return {
      success: true,
      balance: this.#balance,
      message: `Successfully deposited $${amount}`,
    };
  }

  withdraw(amount) {
    const maxWithdraw = this.#balance + this.#overdraftLimit;

    if (amount <= 0) {
      throw new Error("Withdrawal amount must be greater than 0");
    }

    if (amount > maxWithdraw) {
      return {
        success: false,
        balance: this.#balance,
        message: `Insufficient balance, maximum withdrawal $${maxWithdraw}`,
      };
    }

    this.#balance -= amount;
    this.#recordTransaction("WITHDRAW", amount, "Withdrawal");

    return {
      success: true,
      balance: this.#balance,
      message: `Successfully withdrew $${amount}`,
    };
  }

  getBalance() {
    return this.#balance;
  }

  getStatement(limit = 10) {
    return {
      accountNumber: this.#maskAccountNumber(),
      currentBalance: this.#balance,
      recentTransactions: this.#transactionHistory.slice(-limit),
    };
  }

  // Private methods - internal implementation
  #recordTransaction(type, amount, description) {
    this.#transactionHistory.push({
      type,
      amount,
      description,
      balance: this.#balance,
      timestamp: new Date(),
    });
  }

  #maskAccountNumber() {
    const num = this.#accountNumber;
    return `****${num.slice(-4)}`;
  }
}

const account = new UserAccount(1000, 500);

// Can only operate through public methods
account.deposit(200); // ✓ Safe
console.log(account.getBalance()); // 1200

// Invalid operations will be blocked
try {
  account.withdraw(2000); // Exceeds limit
} catch (error) {
  console.log(error.message); // "Insufficient balance, maximum withdrawal $1700"
}

// Cannot directly access private fields
console.log(account.#balance); // SyntaxError
account.#balance = 999999; // SyntaxError

Methods of Implementing Encapsulation in JavaScript

1. Private Fields (Class Private Fields)

ES2022 introduced true private fields using the # prefix:

javascript
class SmartDevice {
  // Private fields
  #deviceId;
  #encryptionKey;
  #isLocked;

  // Public fields
  name;
  model;

  constructor(name, model) {
    this.name = name;
    this.model = model;
    this.#deviceId = this.#generateId();
    this.#encryptionKey = this.#generateKey();
    this.#isLocked = true;
  }

  // Private methods
  #generateId() {
    return `DEVICE-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }

  #generateKey() {
    return Math.random().toString(36).substr(2, 15);
  }

  #encrypt(data) {
    // Simplified encryption logic
    return `encrypted:${data}:${this.#encryptionKey}`;
  }

  #decrypt(encryptedData) {
    // Simplified decryption logic
    const parts = encryptedData.split(":");
    return parts[1];
  }

  // Public methods
  unlock(password) {
    if (password === "correct-password") {
      // In real applications, should be more complex
      this.#isLocked = false;
      console.log(`${this.name} has been unlocked`);
      return true;
    }
    console.log("Incorrect password");
    return false;
  }

  lock() {
    this.#isLocked = true;
    console.log(`${this.name} has been locked`);
  }

  storeData(key, value) {
    if (this.#isLocked) {
      throw new Error("Device is locked, cannot store data");
    }

    const encryptedValue = this.#encrypt(value);
    console.log(`Data encrypted and stored: ${key}`);
    return encryptedValue;
  }

  getDeviceInfo() {
    // Only expose partial information
    return {
      name: this.name,
      model: this.model,
      isLocked: this.#isLocked,
      // deviceId and encryptionKey remain private
    };
  }
}

const phone = new SmartDevice("iPhone", "15 Pro");

console.log(phone.getDeviceInfo());
// { name: 'iPhone', model: '15 Pro', isLocked: true }

phone.unlock("correct-password");
const encrypted = phone.storeData("password", "mySecret123");

// Private fields and methods cannot be accessed from outside
console.log(phone.#deviceId); // SyntaxError
phone.#encrypt("data"); // SyntaxError

2. Implementing Private Data with WeakMap

Before ES2022, WeakMap could be used to simulate private data:

javascript
const privateData = new WeakMap();

class SecureWallet {
  constructor(owner, initialBalance) {
    // Store private data in WeakMap
    privateData.set(this, {
      owner: owner,
      balance: initialBalance,
      pin: this.#generatePin(),
      transactions: [],
    });
  }

  #generatePin() {
    return Math.floor(1000 + Math.random() * 9000);
  }

  #getData() {
    return privateData.get(this);
  }

  #recordTransaction(type, amount) {
    const data = this.#getData();
    data.transactions.push({
      type,
      amount,
      balance: data.balance,
      timestamp: new Date(),
    });
  }

  verifyPin(pin) {
    const data = this.#getData();
    return pin === data.pin;
  }

  deposit(amount, pin) {
    if (!this.verifyPin(pin)) {
      throw new Error("Incorrect PIN");
    }

    if (amount <= 0) {
      throw new Error("Deposit amount must be greater than 0");
    }

    const data = this.#getData();
    data.balance += amount;
    this.#recordTransaction("DEPOSIT", amount);

    return `Deposit successful, current balance: $${data.balance}`;
  }

  withdraw(amount, pin) {
    if (!this.verifyPin(pin)) {
      throw new Error("Incorrect PIN");
    }

    const data = this.#getData();

    if (amount > data.balance) {
      throw new Error("Insufficient balance");
    }

    data.balance -= amount;
    this.#recordTransaction("WITHDRAW", amount);

    return `Withdrawal successful, current balance: $${data.balance}`;
  }

  getBalance(pin) {
    if (!this.verifyPin(pin)) {
      throw new Error("Incorrect PIN");
    }

    return this.#getData().balance;
  }

  getTransactionHistory(pin) {
    if (!this.verifyPin(pin)) {
      throw new Error("Incorrect PIN");
    }

    return [...this.#getData().transactions];
  }
}

const wallet = new SecureWallet("Sarah", 1000);

// Even when viewing the object, private data is not visible
console.log(wallet);
// SecureWallet {}

// Must use correct PIN to operate
const correctPin = 1234; // In real applications, obtained through secure means

try {
  wallet.deposit(500, 9999); // Incorrect PIN
} catch (error) {
  console.log(error.message); // "Incorrect PIN"
}

3. Implementing Encapsulation with Closures

Use closures to create truly private variables:

javascript
function createBankAccount(accountHolder, initialDeposit) {
  // These variables are truly private and can only be accessed through returned methods
  let balance = initialDeposit;
  let accountNumber = generateAccountNumber();
  let transactionLog = [];
  let isActive = true;

  function generateAccountNumber() {
    return `ACC-${Date.now()}-${Math.random()
      .toString(36)
      .substr(2, 6)
      .toUpperCase()}`;
  }

  function validateAmount(amount) {
    if (typeof amount !== "number" || amount <= 0) {
      throw new Error("Amount must be a positive number");
    }
  }

  function log(type, amount, success) {
    transactionLog.push({
      type,
      amount,
      success,
      balance: balance,
      timestamp: new Date(),
    });
  }

  // Return public interface
  return {
    deposit(amount) {
      if (!isActive) {
        throw new Error("Account is frozen");
      }

      try {
        validateAmount(amount);
        balance += amount;
        log("DEPOSIT", amount, true);

        return {
          success: true,
          newBalance: balance,
          message: `Successfully deposited $${amount}`,
        };
      } catch (error) {
        log("DEPOSIT", amount, false);
        throw error;
      }
    },

    withdraw(amount) {
      if (!isActive) {
        throw new Error("Account is frozen");
      }

      try {
        validateAmount(amount);

        if (amount > balance) {
          throw new Error(`Insufficient balance, current balance $${balance}`);
        }

        balance -= amount;
        log("WITHDRAW", amount, true);

        return {
          success: true,
          newBalance: balance,
          message: `Successfully withdrew $${amount}`,
        };
      } catch (error) {
        log("WITHDRAW", amount, false);
        throw error;
      }
    },

    getBalance() {
      return balance;
    },

    getAccountInfo() {
      return {
        accountHolder,
        accountNumber,
        balance,
        isActive,
        totalTransactions: transactionLog.length,
      };
    },

    getStatement(limit = 10) {
      return {
        accountNumber,
        recentTransactions: transactionLog.slice(-limit),
      };
    },

    freezeAccount() {
      isActive = false;
      console.log("Account has been frozen");
    },

    unfreezeAccount() {
      isActive = true;
      console.log("Account has been unfrozen");
    },
  };
}

const myAccount = createBankAccount("John Doe", 5000);

console.log(myAccount.deposit(1000));
// { success: true, newBalance: 6000, message: 'Successfully deposited $1000' }

console.log(myAccount.withdraw(500));
// { success: true, newBalance: 5500, message: 'Successfully withdrew $500' }

console.log(myAccount.getAccountInfo());
// {
//   accountHolder: 'John Doe',
//   accountNumber: 'ACC-...',
//   balance: 5500,
//   isActive: true,
//   totalTransactions: 2
// }

// Cannot directly access private variables
console.log(myAccount.balance); // undefined
console.log(myAccount.accountNumber); // undefined
console.log(myAccount.transactionLog); // undefined

Accessor Properties (Getters and Setters)

Getters and Setters provide controlled access to properties:

javascript
class Temperature {
  #celsius;

  constructor(celsius = 0) {
    this.#celsius = celsius;
  }

  // Getter - get Celsius
  get celsius() {
    return this.#celsius;
  }

  // Setter - set Celsius
  set celsius(value) {
    if (typeof value !== "number") {
      throw new Error("Temperature must be a number");
    }
    if (value < -273.15) {
      throw new Error("Temperature cannot be below absolute zero (-273.15°C)");
    }
    this.#celsius = value;
  }

  // Getter - get Fahrenheit
  get fahrenheit() {
    return (this.#celsius * 9) / 5 + 32;
  }

  // Setter - set temperature through Fahrenheit
  set fahrenheit(value) {
    if (typeof value !== "number") {
      throw new Error("Temperature must be a number");
    }
    this.celsius = ((value - 32) * 5) / 9; // Use celsius setter for validation
  }

  // Getter - get Kelvin temperature
  get kelvin() {
    return this.#celsius + 273.15;
  }

  // Setter - set through Kelvin temperature
  set kelvin(value) {
    this.celsius = value - 273.15;
  }

  toString() {
    return `${this.#celsius.toFixed(2)}°C = ${this.fahrenheit.toFixed(
      2
    )}°F = ${this.kelvin.toFixed(2)}K`;
  }
}

const temp = new Temperature(25);

// Use getter like accessing a property
console.log(temp.celsius); // 25
console.log(temp.fahrenheit); // 77
console.log(temp.kelvin); // 298.15

// Use setter like setting a property, but with validation
temp.celsius = 100;
console.log(temp.toString());
// 100.00°C = 212.00°F = 373.15K

temp.fahrenheit = 32; // Set to freezing point
console.log(temp.celsius); // 0

// Error handling
try {
  temp.celsius = -300; // Below absolute zero
} catch (error) {
  console.log(error.message);
  // "Temperature cannot be below absolute zero (-273.15°C)"
}

Computed Properties and Validation

javascript
class Rectangle {
  #width;
  #height;

  constructor(width, height) {
    this.width = width; // Use setter
    this.height = height; // Use setter
  }

  get width() {
    return this.#width;
  }

  set width(value) {
    if (value <= 0) {
      throw new Error("Width must be greater than 0");
    }
    this.#width = value;
  }

  get height() {
    return this.#height;
  }

  set height(value) {
    if (value <= 0) {
      throw new Error("Height must be greater than 0");
    }
    this.#height = value;
  }

  // Computed property - only getter
  get area() {
    return this.#width * this.#height;
  }

  get perimeter() {
    return 2 * (this.#width + this.#height);
  }

  get diagonal() {
    return Math.sqrt(this.#width ** 2 + this.#height ** 2);
  }

  get aspectRatio() {
    const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b));
    const divisor = gcd(this.#width, this.#height);
    return `${this.#width / divisor}:${this.#height / divisor}`;
  }

  // Can set area, but maintains aspect ratio
  set area(newArea) {
    const currentArea = this.area;
    const scale = Math.sqrt(newArea / currentArea);
    this.#width *= scale;
    this.#height *= scale;
  }

  toString() {
    return `Rectangle(${this.#width} × ${this.#height})
  Area: ${this.area}
  Perimeter: ${this.perimeter.toFixed(2)}
  Diagonal: ${this.diagonal.toFixed(2)}
  Aspect Ratio: ${this.aspectRatio}`;
  }
}

const rect = new Rectangle(16, 9);

console.log(rect.area); // 144
console.log(rect.perimeter); // 50
console.log(rect.aspectRatio); // "16:9"

// Modify dimensions
rect.width = 20;
rect.height = 10;
console.log(rect.area); // 200

// Scale by setting area
rect.area = 100;
console.log(rect.width, rect.height);
// Both values are scaled proportionally

console.log(rect.toString());

Design Principles of Encapsulation

1. Principle of Least Knowledge

Objects should only expose necessary interfaces and hide internal implementation:

javascript
class EmailService {
  #apiKey;
  #apiEndpoint;
  #rateLimiter;

  constructor(apiKey) {
    this.#apiKey = apiKey;
    this.#apiEndpoint = "https://api.emailservice.com";
    this.#rateLimiter = new RateLimiter(100); // 100 per minute
  }

  // Private methods - internal implementation details
  #buildHeaders() {
    return {
      Authorization: `Bearer ${this.#apiKey}`,
      "Content-Type": "application/json",
    };
  }

  #validateEmail(email) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      throw new Error(`Invalid email address: ${email}`);
    }
  }

  #checkRateLimit() {
    if (!this.#rateLimiter.canSend()) {
      throw new Error("Send rate exceeded, please try again later");
    }
  }

  async #makeRequest(endpoint, data) {
    const response = await fetch(`${this.#apiEndpoint}${endpoint}`, {
      method: "POST",
      headers: this.#buildHeaders(),
      body: JSON.stringify(data),
    });

    if (!response.ok) {
      throw new Error(`API Error: ${response.status}`);
    }

    return await response.json();
  }

  // Public interface - simple and easy to use
  async sendEmail(to, subject, body) {
    this.#validateEmail(to);
    this.#checkRateLimit();

    const result = await this.#makeRequest("/send", {
      to,
      subject,
      body,
      timestamp: new Date().toISOString(),
    });

    this.#rateLimiter.recordSent();

    return {
      success: true,
      messageId: result.id,
      to,
      subject,
    };
  }

  async sendBulkEmails(recipients) {
    const results = [];

    for (const recipient of recipients) {
      try {
        const result = await this.sendEmail(
          recipient.email,
          recipient.subject,
          recipient.body
        );
        results.push({ ...result, status: "sent" });
      } catch (error) {
        results.push({
          to: recipient.email,
          status: "failed",
          error: error.message,
        });
      }
    }

    return {
      total: recipients.length,
      sent: results.filter((r) => r.status === "sent").length,
      failed: results.filter((r) => r.status === "failed").length,
      results,
    };
  }
}

class RateLimiter {
  #maxPerMinute;
  #sentCount;
  #resetTime;

  constructor(maxPerMinute) {
    this.#maxPerMinute = maxPerMinute;
    this.#sentCount = 0;
    this.#resetTime = Date.now() + 60000;
  }

  canSend() {
    this.#checkReset();
    return this.#sentCount < this.#maxPerMinute;
  }

  recordSent() {
    this.#checkReset();
    this.#sentCount++;
  }

  #checkReset() {
    if (Date.now() >= this.#resetTime) {
      this.#sentCount = 0;
      this.#resetTime = Date.now() + 60000;
    }
  }
}

// Usage - simple and clear
const emailService = new EmailService("api-key-123");

// Users only need to know how to send emails, don't need to understand:
// - How API keys are stored
// - How request headers are built
// - How rate limiting is implemented
// - Specific API endpoint addresses
await emailService.sendEmail(
  "[email protected]",
  "Welcome!",
  "Thanks for joining!"
);

2. Single Responsibility Principle

Each class should have only one reason to change:

javascript
// Good design - separated responsibilities
class User {
  #id;
  #name;
  #email;
  #passwordHash;

  constructor(id, name, email) {
    this.#id = id;
    this.#name = name;
    this.#email = email;
  }

  setPassword(hashedPassword) {
    this.#passwordHash = hashedPassword;
  }

  verifyPassword(hashedPassword) {
    return this.#passwordHash === hashedPassword;
  }

  updateEmail(newEmail) {
    // Validate email format
    if (!this.#isValidEmail(newEmail)) {
      throw new Error("Invalid email address");
    }
    this.#email = newEmail;
  }

  #isValidEmail(email) {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }

  toJSON() {
    return {
      id: this.#id,
      name: this.#name,
      email: this.#email,
    };
  }
}

// Password hashing - separate class
class PasswordHasher {
  #salt;

  constructor() {
    this.#salt = this.#generateSalt();
  }

  #generateSalt() {
    return Math.random().toString(36).substring(2);
  }

  hash(password) {
    // Simplified hashing logic
    return `${password}${this.#salt}`
      .split("")
      .reduce((hash, char) => (hash << 5) - hash + char.charCodeAt(0), 0)
      .toString(36);
  }

  verify(password, hash) {
    return this.hash(password) === hash;
  }
}

// User persistence - separate class
class UserRepository {
  #users;

  constructor() {
    this.#users = new Map();
  }

  save(user) {
    const data = user.toJSON();
    this.#users.set(data.id, data);
    return data.id;
  }

  findById(id) {
    return this.#users.get(id) || null;
  }

  findByEmail(email) {
    for (const user of this.#users.values()) {
      if (user.email === email) {
        return user;
      }
    }
    return null;
  }

  delete(id) {
    return this.#users.delete(id);
  }
}

// User authentication service - coordinates various components
class AuthService {
  #userRepository;
  #passwordHasher;

  constructor(userRepository, passwordHasher) {
    this.#userRepository = userRepository;
    this.#passwordHasher = passwordHasher;
  }

  register(name, email, password) {
    // Check if email already exists
    if (this.#userRepository.findByEmail(email)) {
      throw new Error("Email is already registered");
    }

    // Create user
    const user = new User(Date.now(), name, email);

    // Hash password
    const hashedPassword = this.#passwordHasher.hash(password);
    user.setPassword(hashedPassword);

    // Save user
    this.#userRepository.save(user);

    return { success: true, message: "Registration successful" };
  }

  login(email, password) {
    // Find user
    const userData = this.#userRepository.findByEmail(email);
    if (!userData) {
      return { success: false, message: "User does not exist" };
    }

    // Verify password
    const user = new User(userData.id, userData.name, userData.email);
    // Note: In real applications, need to read password hash from database

    return {
      success: true,
      message: "Login successful",
      user: userData,
    };
  }
}

// Usage
const userRepo = new UserRepository();
const hasher = new PasswordHasher();
const authService = new AuthService(userRepo, hasher);

authService.register("Alice", "[email protected]", "password123");
const result = authService.login("[email protected]", "password123");
console.log(result);

Practical Applications of Encapsulation

Cache Manager

javascript
class CacheManager {
  #cache;
  #maxSize;
  #ttl; // time to live in milliseconds

  constructor(maxSize = 100, ttl = 3600000) {
    // Default 1 hour
    this.#cache = new Map();
    this.#maxSize = maxSize;
    this.#ttl = ttl;
  }

  #isExpired(entry) {
    return Date.now() - entry.timestamp > this.#ttl;
  }

  #evictOldest() {
    if (this.#cache.size >= this.#maxSize) {
      const oldestKey = this.#cache.keys().next().value;
      this.#cache.delete(oldestKey);
    }
  }

  #cleanExpired() {
    for (const [key, entry] of this.#cache.entries()) {
      if (this.#isExpired(entry)) {
        this.#cache.delete(key);
      }
    }
  }

  set(key, value) {
    this.#cleanExpired();
    this.#evictOldest();

    this.#cache.set(key, {
      value,
      timestamp: Date.now(),
      hits: 0,
    });
  }

  get(key) {
    const entry = this.#cache.get(key);

    if (!entry) {
      return null;
    }

    if (this.#isExpired(entry)) {
      this.#cache.delete(key);
      return null;
    }

    entry.hits++;
    entry.lastAccess = Date.now();

    return entry.value;
  }

  has(key) {
    return this.get(key) !== null;
  }

  delete(key) {
    return this.#cache.delete(key);
  }

  clear() {
    this.#cache.clear();
  }

  getStats() {
    this.#cleanExpired();

    let totalHits = 0;
    let avgAge = 0;
    const now = Date.now();

    for (const entry of this.#cache.values()) {
      totalHits += entry.hits;
      avgAge += now - entry.timestamp;
    }

    return {
      size: this.#cache.size,
      maxSize: this.#maxSize,
      totalHits,
      avgHits: totalHits / this.#cache.size || 0,
      avgAge: avgAge / this.#cache.size || 0,
    };
  }
}

const cache = new CacheManager(50, 60000); // 50 items, 1 minute TTL

cache.set("user:1", { name: "John", email: "[email protected]" });
cache.set("user:2", { name: "Sarah", email: "[email protected]" });

console.log(cache.get("user:1"));
// { name: 'John', email: '[email protected]' }

console.log(cache.getStats());
// { size: 2, maxSize: 50, totalHits: 1, ... }

Best Practices for Encapsulation

  1. Default to Private: Unless explicitly needed, all members should be private
  2. Clear Interface: Public methods should have clear naming and documentation
  3. Validate Input: Public methods should validate all input
  4. Return Copies: When returning internal objects, return copies rather than references
  5. Immutability: Consider making certain parts of the object immutable

Summary

Encapsulation is a core principle of object-oriented programming that works through:

  • Data Hiding: Protects the internal state of objects
  • Interface Simplification: Only exposes necessary operations
  • Implementation Hiding: Allows changing internal implementation without affecting external code

Proper use of encapsulation can:

  • Improve code security and robustness
  • Reduce system complexity
  • Enhance code maintainability and extensibility
  • Prevent inappropriate usage