Learning Objectives

  • Understand encapsulation and its benefits
  • Create private variables using closures
  • Implement the Module Pattern
  • Use ES2022 private fields
  • Apply getters and setters effectively

What is Encapsulation?

Encapsulation is the practice of bundling data and methods that operate on that data within a single unit, while hiding the internal implementation details from the outside world. It's one of the four fundamental principles of object-oriented programming (along with inheritance, polymorphism, and abstraction).

In JavaScript, encapsulation helps you:

  • Protect data - Prevent direct access to internal state
  • Control access - Define how data can be read and modified
  • Reduce coupling - Hide implementation details from other code
  • Improve maintainability - Change internals without breaking external code
// Without encapsulation - data is exposed
const user = {
    balance: 1000
};

user.balance = -500; // Oops! No validation

// With encapsulation - data is protected
function createUser(initialBalance) {
    let balance = initialBalance; // Private variable
    
    return {
        getBalance() {
            return balance;
        },
        deposit(amount) {
            if (amount > 0) {
                balance += amount;
                return true;
            }
            return false;
        },
        withdraw(amount) {
            if (amount > 0 && amount <= balance) {
                balance -= amount;
                return true;
            }
            return false;
        }
    };
}

const account = createUser(1000);
console.log(account.getBalance()); // 1000
account.deposit(500);              // true
console.log(account.getBalance()); // 1500
account.balance = -500;            // Has no effect!
console.log(account.getBalance()); // Still 1500

Private Variables with Closures

Before ES2022, closures were the primary way to create private variables in JavaScript. A closure allows an inner function to access variables from its outer function, even after the outer function has returned.

function Counter() {
    let count = 0; // Private variable
    
    this.increment = function() {
        count++;
        return count;
    };
    
    this.decrement = function() {
        count--;
        return count;
    };
    
    this.getCount = function() {
        return count;
    };
}

const counter = new Counter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.getCount());  // 2
console.log(counter.count);       // undefined - private!

The count variable is only accessible through the methods we've defined. There's no way to directly access or modify it from outside.

The Module Pattern

The Module Pattern uses an Immediately Invoked Function Expression (IIFE) to create a private scope and return a public API:

const Calculator = (function() {
    // Private variables and functions
    let history = [];
    
    function log(operation, result) {
        history.push({ operation, result, timestamp: Date.now() });
    }
    
    // Public API
    return {
        add(a, b) {
            const result = a + b;
            log(`${a} + ${b}`, result);
            return result;
        },
        
        subtract(a, b) {
            const result = a - b;
            log(`${a} - ${b}`, result);
            return result;
        },
        
        getHistory() {
            return [...history]; // Return copy, not reference
        },
        
        clearHistory() {
            history = [];
        }
    };
})();

console.log(Calculator.add(5, 3));      // 8
console.log(Calculator.subtract(10, 4)); // 6
console.log(Calculator.getHistory());    // Array with 2 operations
console.log(Calculator.history);         // undefined - private!

The Module Pattern is excellent for creating singleton objects with private state and a clean public interface.

ES2022 Private Fields

Modern JavaScript (ES2022) introduced true private fields using the # prefix. These are enforced by the JavaScript engine itself:

class BankAccount {
    #balance;        // Private field
    #transactions;   // Private field
    
    constructor(initialBalance) {
        this.#balance = initialBalance;
        this.#transactions = [];
    }
    
    deposit(amount) {
        if (amount <= 0) {
            throw new Error('Amount must be positive');
        }
        this.#balance += amount;
        this.#logTransaction('deposit', amount);
        return this.#balance;
    }
    
    withdraw(amount) {
        if (amount <= 0) {
            throw new Error('Amount must be positive');
        }
        if (amount > this.#balance) {
            throw new Error('Insufficient funds');
        }
        this.#balance -= amount;
        this.#logTransaction('withdraw', amount);
        return this.#balance;
    }
    
    getBalance() {
        return this.#balance;
    }
    
    // Private method
    #logTransaction(type, amount) {
        this.#transactions.push({
            type,
            amount,
            balance: this.#balance,
            timestamp: new Date()
        });
    }
    
    getTransactionHistory() {
        return [...this.#transactions]; // Return copy
    }
}

const account = new BankAccount(1000);
account.deposit(500);
console.log(account.getBalance());        // 1500
// account.#balance;                      // SyntaxError!
// account.#logTransaction('hack', 999);  // SyntaxError!

Private fields are truly private - attempting to access them from outside the class results in a syntax error, not just undefined.

WeakMap for Private Data

Before private fields, WeakMaps were used to store truly private data:

const privateData = new WeakMap();

class User {
    constructor(name, email, password) {
        // Store private data in WeakMap
        privateData.set(this, {
            password: password,
            loginAttempts: 0
        });
        
        // Public properties
        this.name = name;
        this.email = email;
    }
    
    login(password) {
        const data = privateData.get(this);
        
        if (password === data.password) {
            data.loginAttempts = 0;
            return true;
        }
        
        data.loginAttempts++;
        if (data.loginAttempts >= 3) {
            throw new Error('Account locked');
        }
        return false;
    }
    
    changePassword(oldPassword, newPassword) {
        const data = privateData.get(this);
        
        if (oldPassword !== data.password) {
            throw new Error('Incorrect password');
        }
        
        data.password = newPassword;
        return true;
    }
}

const user = new User('Alice', 'alice@example.com', 'secret123');
console.log(user.name);     // 'Alice'
console.log(user.password); // undefined - private!
user.login('wrong');        // false
user.login('secret123');    // true

WeakMaps are still useful when you need to attach private data to objects you don't control, but private fields are now the preferred approach for classes.

Getters and Setters

Getters and setters provide controlled access to private data with validation:

class Temperature {
    #celsius;
    
    constructor(celsius) {
        this.celsius = celsius; // Uses setter
    }
    
    get celsius() {
        return this.#celsius;
    }
    
    set celsius(value) {
        if (typeof value !== 'number') {
            throw new Error('Temperature must be a number');
        }
        if (value < -273.15) {
            throw new Error('Temperature below absolute zero');
        }
        this.#celsius = value;
    }
    
    get fahrenheit() {
        return (this.#celsius * 9/5) + 32;
    }
    
    set fahrenheit(value) {
        this.celsius = (value - 32) * 5/9; // Uses celsius setter
    }
    
    get kelvin() {
        return this.#celsius + 273.15;
    }
    
    set kelvin(value) {
        this.celsius = value - 273.15; // Uses celsius setter
    }
}

const temp = new Temperature(25);
console.log(temp.celsius);     // 25
console.log(temp.fahrenheit);  // 77
console.log(temp.kelvin);      // 298.15

temp.fahrenheit = 32;
console.log(temp.celsius);     // 0

// temp.celsius = -300;        // Error: Temperature below absolute zero

Real-World Example: Shopping Cart

Let's build a shopping cart with proper encapsulation:

class ShoppingCart {
    #items;
    #discountRate;
    
    constructor() {
        this.#items = [];
        this.#discountRate = 0;
    }
    
    addItem(product, quantity = 1) {
        if (quantity <= 0) {
            throw new Error('Quantity must be positive');
        }
        
        const existingItem = this.#items.find(item => item.product.id === product.id);
        
        if (existingItem) {
            existingItem.quantity += quantity;
        } else {
            this.#items.push({ product, quantity });
        }
        
        return this.#calculateTotal();
    }
    
    removeItem(productId) {
        const index = this.#items.findIndex(item => item.product.id === productId);
        
        if (index !== -1) {
            this.#items.splice(index, 1);
        }
        
        return this.#calculateTotal();
    }
    
    updateQuantity(productId, quantity) {
        if (quantity <= 0) {
            return this.removeItem(productId);
        }
        
        const item = this.#items.find(item => item.product.id === productId);
        
        if (item) {
            item.quantity = quantity;
        }
        
        return this.#calculateTotal();
    }
    
    applyDiscount(percentage) {
        if (percentage < 0 || percentage > 100) {
            throw new Error('Discount must be between 0 and 100');
        }
        this.#discountRate = percentage / 100;
        return this.#calculateTotal();
    }
    
    #calculateTotal() {
        const subtotal = this.#items.reduce((sum, item) => {
            return sum + (item.product.price * item.quantity);
        }, 0);
        
        const discount = subtotal * this.#discountRate;
        return subtotal - discount;
    }
    
    getItems() {
        // Return deep copy to prevent external modification
        return this.#items.map(item => ({
            product: { ...item.product },
            quantity: item.quantity
        }));
    }
    
    getTotal() {
        return this.#calculateTotal();
    }
    
    getItemCount() {
        return this.#items.reduce((sum, item) => sum + item.quantity, 0);
    }
    
    clear() {
        this.#items = [];
        this.#discountRate = 0;
    }
}

// Usage
const cart = new ShoppingCart();

cart.addItem({ id: 1, name: 'Laptop', price: 999 }, 1);
cart.addItem({ id: 2, name: 'Mouse', price: 25 }, 2);

console.log(cart.getTotal());      // 1049
console.log(cart.getItemCount());  // 3

cart.applyDiscount(10);            // 10% off
console.log(cart.getTotal());      // 944.10

// cart.#items.push(...);          // SyntaxError - can't access private field!
// cart.#discountRate = 0.5;       // SyntaxError - can't access private field!

Encapsulation Best Practices

1. Make Everything Private by Default

Only expose what's necessary for the public API:

class DataProcessor {
    #data;
    #cache;
    
    constructor(data) {
        this.#data = data;
        this.#cache = new Map();
    }
    
    // Public method
    process() {
        return this.#validate() && this.#transform() && this.#save();
    }
    
    // Private methods
    #validate() { /* ... */ }
    #transform() { /* ... */ }
    #save() { /* ... */ }
}

2. Use Getters for Computed Properties

class Rectangle {
    #width;
    #height;
    
    constructor(width, height) {
        this.#width = width;
        this.#height = height;
    }
    
    get area() {
        return this.#width * this.#height;
    }
    
    get perimeter() {
        return 2 * (this.#width + this.#height);
    }
}

3. Validate in Setters

class Person {
    #age;
    
    set age(value) {
        if (typeof value !== 'number' || value < 0 || value > 150) {
            throw new Error('Invalid age');
        }
        this.#age = value;
    }
    
    get age() {
        return this.#age;
    }
}

4. Return Copies, Not References

class Team {
    #members;
    
    constructor() {
        this.#members = [];
    }
    
    getMembers() {
        // Return copy to prevent external modification
        return [...this.#members];
    }
}

5. Use Meaningful Method Names

// Good - clear intent
class User {
    #isActive;
    
    activate() { this.#isActive = true; }
    deactivate() { this.#isActive = false; }
    isActive() { return this.#isActive; }
}

// Bad - unclear
class User {
    #status;
    
    set(val) { this.#status = val; }
    get() { return this.#status; }
}

When to Use Encapsulation

Use encapsulation when:

  • You need to protect data integrity
  • You want to control how data is accessed or modified
  • You're building reusable components or libraries
  • You need to validate data before changes
  • You want to hide complex implementation details
  • You need to maintain backward compatibility

Consider simpler approaches when:

  • You're building simple data structures
  • The code is only used internally in your team
  • Performance is critical and the overhead matters
  • The data doesn't need protection or validation

Common Pitfalls

1. Over-Encapsulation

// Bad - too much encapsulation for a simple point
class Point {
    #x;
    #y;
    
    getX() { return this.#x; }
    setX(x) { this.#x = x; }
    getY() { return this.#y; }
    setY(y) { this.#y = y; }
}

// Better - simple data structure
class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
}

2. Forgetting to Return Copies

// Bad - exposes internal array
class List {
    #items = [];
    
    getItems() {
        return this.#items; // External code can modify this!
    }
}

// Good - returns copy
class List {
    #items = [];
    
    getItems() {
        return [...this.#items];
    }
}

3. Inconsistent Access Patterns

// Bad - mixing patterns
class User {
    #name;
    email; // Public
    
    getName() { return this.#name; }
}

// Good - consistent
class User {
    #name;
    #email;
    
    getName() { return this.#name; }
    getEmail() { return this.#email; }
}

Key Takeaways

  • Encapsulation bundles data and methods while hiding implementation details
  • Use closures for private variables in functions and modules
  • The Module Pattern creates singletons with private state
  • ES2022 private fields (#) are the modern way to create truly private class members
  • WeakMaps can store private data for objects you don't control
  • Getters and setters provide controlled access with validation
  • Always return copies of internal data structures, not references
  • Make everything private by default, only expose what's necessary
  • Use meaningful names for public methods
  • Don't over-encapsulate simple data structures