Encapsulation is one of the fundamental principles of object-oriented programming. It refers to bundling data and methods that operate on that data within a single unit, or object. Encapsulation in JavaScript is implemented through closures and scopes.
Why Encapsulation Matters
Encapsulation is important for several reasons:
-
It facilitates modular and reusable code. By containing related properties and behaviors within an object, that object can be easily passed around and reused without worrying about scope issues.
-
It enables abstraction. The internal implementation of an object can be hidden from other code, exposing only a public interface. This allows for changes to be made more easily without breaking dependent code.
-
It helps organize code. Related data and methods are grouped together, making code more readable and maintainable.
-
It protects data integrity. By preventing external code from directly manipulating an object‘s internal data, encapsulation reduces bugs and unexpected behavior.
Private Properties in JavaScript
Unlike some other languages, JavaScript does not have built-in support for declaring private properties. However, encapsulation can still be achieved by leveraging closures and scopes.
Here are some common patterns for creating private properties in JavaScript:
Using Closures
A closure is formed when a nested inner function accesses variables from its outer scope. The inner function maintains a reference to the outer scope, thus preserving any variables defined there.
We can use this to create private properties:
function Person() {
// Private property only accessible inside Person
var privateAge = 0;
this.getAge = function() {
return privateAge;
};
this.setAge = function(newAge) {
// Modify private property
privateAge = newAge;
};
}
var person = new Person();
person.getAge(); // 0
person.setAge(30);
person.getAge(); // 30
The privateAge variable is protected inside the scope of the Person constructor function. The public getAge and setAge methods are the interfaces that external code must go through to read or modify it.
Using WeakMaps
Introduced in ES6, WeakMaps provide another way to store private data. The key difference from normal Maps is that only objects can be used as keys.
This works well for encapsulation:
const PrivateData = new WeakMap();
class Person {
constructor() {
PrivateData.set(this, { age: 0 });
}
getAge() {
return PrivateData.get(this).age;
}
setAge(newAge) {
PrivateData.get(this).age = newAge;
}
}
let person = new Person();
person.getAge(); // 0
The PrivateData WeakMap holds the private age property. It is keyed to each Person instance, allowing access only through the class‘s public methods.
Using Symbols
Symbols are a new primitive type in ES6 that can be used to store private object properties:
const ageSymbol = Symbol(‘age‘);
class Person {
constructor() {
this[ageSymbol] = 0;
}
getAge() {
return this[ageSymbol];
}
setAge(newAge) {
this[ageSymbol] = newAge;
}
}
let person = new Person();
person.getAge(); // 0
Here the Symbol ageSymbol represents the key to a private property. This technique has the benefit that the property will not show up in enumeration like for...in loops, further hiding its private nature.
Private Methods
So far we‘ve focused on encapsulating data, but what about encapsulating behaviors by making methods private?
JavaScript lacks a native way to declare private methods like some class-based languages. But similar techniques can be employed:
function Person() {
var privateMethod = function() {
console.log(‘Inside private method‘);
};
this.publicMethod = function() {
privateMethod(); // Access private method
};
}
let p = new Person();
p.privateMethod(); // Error, not accessible
p.publicMethod(); // "Inside private method"
Here privateMethod is only available inside the scope of the Person constructor. It can only be indirectly accessed through the privileged publicMethod.
Alternatively with ES6 classes:
class Person {
#privateMethod() {
console.log(‘Inside private method‘);
}
publicMethod() {
this.#privateMethod();
}
}
let p = new Person();
p.#privateMethod(); // SyntaxError
p.publicMethod(); // "Inside private method"
This uses the relatively new private fields proposal to declare #privateMethod. Outside code cannot call it directly or overwrite it.
So in JavaScript, encapsulation comes down to controlling access rather than having hard private keywords.
Benefits of Proper Encapsulation
Let‘s look at some examples that demonstrate the benefits of encapsulation:
Data Validation
With properties made private, we can add validation logic that gets called automatically whenever data is attempted to be modified:
function BankAccount() {
var balance = 0;
this.getBalance = function() {
return balance;
};
this.deposit = function(amount) {
if (amount > 0) {
balance += amount;
return balance;
} else {
throw new Error(‘Invalid deposit amount‘);
}
};
}
account = new BankAccount();
account.getBalance(); // 0
account.deposit(10); // 10
account.deposit(-20); // Error: Invalid deposit amount
By funneling all property updates through the deposit method, we can easily validate data before modifying it. This prevents invalid balance amounts from ever entering the system.
Enforce Invariants
An invariant is a condition that must always be true for an object. Encapsulation allows invariants to be enforced internally:
function Square(width) {
// Always preserve invariant
var side = width > 0 ? width : 0;
this.getSide = function() {
return side;
};
this.setSide = function(newSide) {
side = newSide > 0 ? newSide : side;
};
}
let square = new Square(-10);
square.getSide(); // 0
square.setSide(5);
square.getSide(); // 5
Here the invariant is that the square‘s side must always be positive. By controlling access through privileged methods, we can easily enforce that constraint.
Interface Stability
Well-designed interfaces improve stability by reducing the chance that changes need to be made externally:
function Library() {
var books = []; // private
this.addBook = function(book) {
books.push(book);
};
this.printBooks = function() {
books.forEach(book => console.log(book));
}
}
// External code only uses stable public interface
let lib = new Library();
lib.addBook(‘Don Quixote‘);
lib.printBooks();
// Later we can improve internals without affecting external code
function Library() {
var books = [];
// Improved private storage
var idToBook = new Map();
this.addBook = function(id, book) {
books.push(book);
idToBook.set(id, book);
};
// Same public interface
this.printBooks = function() {
books.forEach(book => console.log(book));
}
}
Because external code only accesses Library through a limited public API, we are free to modify private state management as needed. Existing code is unaffected by changes to internal implementation details.
JavaScript Encapsulation Libraries
Implementing rigorous encapsulation by hand can become tedious in larger programs. Some third-party libraries aim to make it easier:
TypeScript
TypeScript extends JavaScript by adding features like class access modifiers:
class Person {
private age: number = 0;
public getAge() {
return this.age;
}
protected setAge(age: number) {
if (age >= 0 && age < 200) {
this.age = age;
}
}
}
let person = new Person();
// Enforced encapsulation
person.age = 5; // Error
This enforces encapsulation of the age property and setAge method at the language level.
JSClass
The JSClass library brings class-like encapsulation to vanilla JavaScript:
class Person extends JSClass {
const init = function(age) {
this._age = age;
}
get age() {
return this._age;
}
set age(value) {
this._age = value;
}
}
let person = new Person(0);
person.age = 5;
person.age; // 5
person._age = 500; // No effect, still private
person.age; // 5
Methods preceded by get/set turn properties into getter/setters. External code cannot circumvent these accessors, keeping the data private.
Encapsulation Anti-Patterns to Avoid
As important as encapsulation is, there are some common anti-patterns that should be avoided:
Exposing Internals
It can be tempting to simply expose internal state for the sake of convenience:
class Person {
constructor() {
// Public field
this.age = 0;
}
}
let person = new Person();
// Bypass interface, modify directly
person.age = 500;
But this gives up all the protections of encapsulation. It tightly couples classes to their internals, so any changes risk breaking dependent code.
Overprivileged Methods
Granting access to behaviors that certain objects do not need prevents proper encapsulation:
class Car {
drive() {
engine.start(); // Internal engine access
}
}
let myCar = new Car();
myCar.drive();
// Overprivileged method
function hotwire(car) {
car.engine.start(); // Should not have direct engine access
}
hotwire(myCar);
Here hotwire is able to bypass the drive interface and directly control the engine. This could have unintended consequences.
Methods should only expose what is essential for other objects to use them properly.
Getters/Setters for Everything
While getters and setters are great for encapsulation, it may be overkill to provide them for every field:
class Person {
getName() {
return this.name;
}
setName(name) {
this.name = name;
}
getBirthDay() {
return this.birthday;
}
setBirthDay(birthday) {
this.birthday = birthday;
}
// ... Getters/setters for every property
}
This obfuscates the public interface, exposes unnecessary implementation details, and costs performance. Getters and setters should encapsulate access only to sensitive properties that need to validate or transform data.
Conclusion
Encapsulation is a critical concept in object-oriented JavaScript. Mastering techniques like closures and scopes allows bundling of data with the methods that govern access to that data.
Proper encapsulation aids reusability, reduces coupling, enables abstraction, and protects integrity of objects. It does come with trade-offs though – encapsulated systems tend to be less convenience to use and require more code.
Nonetheless, for building robust JavaScript applications, carefully balancing encapsulation and clean interfaces with flexibility and transparency is key.


