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:
- Data Hiding: Hide the internal state of objects to prevent direct external access and modification
- Interface Simplification: Only expose necessary public methods, hiding complex implementation details
Let's understand the importance of encapsulation through a practical example:
// 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.
// 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; // SyntaxErrorMethods of Implementing Encapsulation in JavaScript
1. Private Fields (Class Private Fields)
ES2022 introduced true private fields using the # prefix:
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"); // SyntaxError2. Implementing Private Data with WeakMap
Before ES2022, WeakMap could be used to simulate private data:
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:
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); // undefinedAccessor Properties (Getters and Setters)
Getters and Setters provide controlled access to properties:
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
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:
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:
// 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
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
- Default to Private: Unless explicitly needed, all members should be private
- Clear Interface: Public methods should have clear naming and documentation
- Validate Input: Public methods should validate all input
- Return Copies: When returning internal objects, return copies rather than references
- 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