📖 Encapsulation in JavaScript

Encapsulation is one of the core principles of Object-Oriented Programming (OOP). It refers to the bundling of data (properties) and methods (functions) that operate on that data into a single unit, typically a class. Encapsulation also involves restricting direct access to some of the object's components, which helps to prevent the accidental modification of data.

In JavaScript, properties and methods of a class are publicly accessible by default, meaning that any code outside the class can access and modify them. However, encapsulation allows us to control how and where these properties and methods can be accessed and modified.

Public Properties and Methods

By default, all properties and methods in a JavaScript class are public, which means they can be accessed from outside the class. This is useful, but it also means that the internal state of an object can be modified in unintended ways.

class Student {
    constructor(name) {
        this.name = name;
    }

    getName() {
        return this.name;
    }
}

const student = new Student('John Doe');
console.log(student.name); // Outputs: John Doe
student.name = 'Jane Doe'; // Modifies the name directly
console.log(student.getName()); // Outputs: Jane Doe

In this example, the name property is publicly accessible, which means it can be modified directly from outside the class. While this is sometimes desired, it can also lead to unexpected behavior if the property is changed in ways that the class was not designed to handle.

Private Properties and Methods

JavaScript introduced private fields with the # syntax in ES6, allowing developers to create properties that cannot be accessed or modified directly from outside the class. This helps to protect the internal state of an object.

class Student {
    #name; // Private property

    constructor(name) {
        this.#name = name;
    }

    getName() {
        return this.#name;
    }
}

const student = new Student('John Doe');
console.log(student.getName()); // Outputs: John Doe
console.log(student.#name); // SyntaxError: Private field '#name' must be declared in an enclosing class

In this example, the #name property is private, meaning it cannot be accessed directly from outside the class. This helps to enforce encapsulation by ensuring that the internal state of the Student object is protected from unintended modifications.

Access Control in Encapsulation

Encapsulation in JavaScript can also be managed through the use of getter and setter methods. These methods allow you to control how properties are accessed and modified, often including validation or additional logic.

Getters and Setters
class Student {
    constructor(name) {
        this._name = name; // Conventionally "protected" property
    }

    get name() {
        return this._name;
    }

    set name(value) {
        if (value.length > 0) {
            this._name = value;
        } else {
            console.log('Name cannot be empty');
        }
    }
}

const student = new Student('Jane Doe');
console.log(student.name); // Outputs: Jane Doe
student.name = ''; // Outputs: Name cannot be empty

In this example, the name property is accessed and modified through getter and setter methods. The setter method includes validation to ensure that the name is not set to an empty string, demonstrating how encapsulation helps to maintain the integrity of an object’s state.

Why Encapsulation Matters

Code Maintainability
By bundling data and methods together and restricting direct access to certain properties, encapsulation makes code easier to maintain and understand.
Data Integrity
Encapsulation helps to prevent accidental or intentional modifications to an object's state that could lead to bugs or inconsistent behavior.
Abstraction
Encapsulation supports abstraction by hiding the internal implementation details of an object and exposing only what is necessary.

Best Practices for Encapsulation

Use private fields (#)
Protect sensitive data within a class by using private fields.
Use getters and setters
Control access to class properties and include validation logic when necessary.
Avoid direct property access
Interact with an object's data through methods whenever possible, rather than accessing properties directly.

Summary

Encapsulation
Encapsulation is the bundling of data and methods into a single unit, with access control over the internal state.
Private Fields
Private fields, introduced with the # syntax, allow for true encapsulation in JavaScript by restricting access to certain properties.
Getters and Setters
Getters and setters provide controlled access to properties and can include validation logic.
Best Practices
Encapsulation helps to maintain code integrity, prevent unintended data modifications, and support abstraction.

Putting It Into Action

Encapsulation in a Bank Account Class

To see how encapsulation works in practice, let's create a simple BankAccount class. This class will have private properties for the account balance and public methods to deposit, withdraw, and check the balance. We'll use getter and setter methods to control access to these properties.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Bank Account</title>
</head>
<body>

    <h1>Bank Account Management</h1>

    <div id="output"></div>

    <script>
        class BankAccount {
            #balance = 0; // Private field

            deposit(amount) {
                if (amount > 0) {
                    this.#balance += amount;
                    console.log(`Deposited: $${amount}`);

                    // output to DOM
                    this.outputToDOM(`Deposited: $${amount}`);
                }
            }

            withdraw(amount) {
                if (amount > 0 && amount <= this.#balance) {
                    this.#balance -= amount;
                    console.log(`Withdrew: $${amount}`);

                    // output to DOM
                    this.outputToDOM(`Withdrew: $${amount}`);
                } else {
                    console.log('Insufficient funds or invalid amount');

                    // output to DOM
                    this.outputToDOM(message);
                }
            }

            getBalance() {
                return this.#balance;
            }

            outputToDOM(message) {
                const outputDiv = document.getElementById('output');
                const p = document.createElement('p');
                p.textContent = message;
                outputDiv.appendChild(p);
            }
        }

        const account = new BankAccount();
        account.deposit(100);
        account.withdraw(50);
        console.log(`Current Balance: $${account.getBalance()}`); // Outputs: Current Balance: $50

        // Output the final balance to the DOM
        const outputDiv = document.getElementById('output');
        const p = document.createElement('p');
        p.textContent = `Current Balance: $${account.getBalance()}`;
        outputDiv.appendChild(p);
    </script>

</body>
</html>

This example shows how encapsulation can be used to protect the internal state of a BankAccount object. The #balance property is private, so it cannot be modified directly from outside the class. Instead, the deposit and withdraw methods provide controlled access to modify the balance, and the getBalance method allows you to check the balance.

Challenge

Now that you've seen how encapsulation works in a BankAccount class, try extending this example with the following challenge:

  • Add a method to the BankAccount class that allows setting an account holder's name. Use a setter to ensure the name is not empty.
  • Modify the getBalance method to return the account holder's name along with the balance.

In order to check your learning, you should attempt to create a solution before revealing the provided solution below.

// JavaScript Code for the Challenge Solution

class BankAccount {
    #balance = 0; // Private field
    #accountHolderName; // Private field

    deposit(amount) {
        if (amount > 0) {
            this.#balance += amount;
            console.log(`Deposited: $${amount}`);
        }
    }

    withdraw(amount) {
        if (amount > 0 && amount <= this.#balance) {
            this.#balance -= amount;
            console.log(`Withdrew: $${amount}`);
        } else {
            console.log('Insufficient funds or invalid amount');
        }
    }

    set accountHolderName(name) {
        if (name.length > 0) {
            this.#accountHolderName = name;
        } else {
            console.log('Account holder name cannot be empty');
        }
    }

    getBalance() {
        return `${this.#accountHolderName}'s balance: $${this.#balance}`;
    }
}

const account = new BankAccount();
account.accountHolderName = 'John Doe';
account.deposit(100);
account.withdraw(50);
console.log(account.getBalance()); // Outputs: John Doe's balance: $50
                

References