Encapsulation and Information Hiding: Protecting Your Object's State

Introduction
Encapsulation is often called the first pillar of Object-Oriented Programming, and for good reason. It's the foundation upon which all other OOP principles are built. Without proper encapsulation, your objects become fragile, your code becomes tightly coupled, and maintenance becomes a nightmare.
In this deep dive, we'll explore encapsulation beyond the basics. You'll learn how to protect your object's internal state, when to use getters and setters (and when not to), how to create truly immutable objects, and most importantly—how to avoid the common mistakes that make encapsulation ineffective.
What You'll Learn
✅ Understand encapsulation and information hiding in depth
✅ Use access modifiers (public, private, protected) correctly
✅ Implement getters and setters with proper validation
✅ Create immutable objects for thread safety and predictability
✅ Recognize and fix common encapsulation mistakes
Prerequisites
- Basic understanding of classes and objects
- Completed The Four Pillars of OOP
- Completed Classes, Objects, and Abstraction
What is Encapsulation?
Encapsulation is the bundling of data (attributes) and the methods that operate on that data within a single unit (class), while restricting direct access to some of the object's components.
Think of encapsulation as having two key aspects:
- Bundling: Keeping related data and behavior together in one place
- Information Hiding: Controlling access to internal implementation details
The Capsule Analogy
Imagine a medicine capsule. The active ingredients are protected inside a shell. You don't need to know the exact chemical composition—you just take the capsule and it works. The shell:
- Protects the contents from external contamination
- Controls how the medicine is released
- Hides the complexity of the internal formulation
Similarly, a well-encapsulated class:
- Protects its internal state from invalid modifications
- Controls how data is accessed and modified
- Hides implementation details that shouldn't be exposed
Encapsulation vs Information Hiding
While often used interchangeably, these concepts are subtly different:
| Concept | Focus | Purpose |
|---|---|---|
| Encapsulation | Bundling data and methods together | Organization and cohesion |
| Information Hiding | Restricting access to internals | Protection and flexibility |
Encapsulation answers: "What belongs together?" Information Hiding answers: "What should be hidden?"
You can have encapsulation without information hiding (all members public), but you can't have effective information hiding without encapsulation.
Access Modifiers: The Tools of Encapsulation
Access modifiers control the visibility of class members. Each language implements them slightly differently.
Access Modifier Comparison
| Modifier | TypeScript | Python | Java | Visibility |
|---|---|---|---|---|
| Public | public (default) | No prefix | public | Everywhere |
| Protected | protected | _prefix (convention) | protected | Class + subclasses |
| Private | private | __prefix (name mangling) | private | Class only |
| Package/Internal | N/A | N/A | (default) | Same package |
TypeScript Access Modifiers
class Employee {
// Public: accessible everywhere (default)
public name: string;
// Protected: accessible in class and subclasses
protected department: string;
// Private: accessible only in this class
private salary: number;
// Readonly: cannot be changed after initialization
public readonly employeeId: string;
constructor(
name: string,
department: string,
salary: number,
employeeId: string
) {
this.name = name;
this.department = department;
this.salary = salary;
this.employeeId = employeeId;
}
// Public method - part of the public API
public getAnnualSalary(): number {
return this.salary * 12;
}
// Protected method - usable by subclasses
protected calculateBonus(): number {
return this.salary * 0.1;
}
// Private method - internal implementation detail
private formatCurrency(amount: number): string {
return `$${amount.toLocaleString()}`;
}
public getSalaryReport(): string {
return `Annual: ${this.formatCurrency(this.getAnnualSalary())}`;
}
}
class Manager extends Employee {
private teamSize: number;
constructor(
name: string,
department: string,
salary: number,
employeeId: string,
teamSize: number
) {
super(name, department, salary, employeeId);
this.teamSize = teamSize;
}
public getManagerInfo(): string {
// ✅ Can access public members
console.log(this.name);
// ✅ Can access protected members
console.log(this.department);
const bonus = this.calculateBonus();
// ❌ Cannot access private members
// console.log(this.salary); // Error!
// this.formatCurrency(1000); // Error!
return `${this.name} manages ${this.teamSize} people in ${this.department}`;
}
}
// Usage
const manager = new Manager("Alice", "Engineering", 100000, "EMP001", 10);
console.log(manager.name); // ✅ Public - accessible
console.log(manager.employeeId); // ✅ Readonly public - accessible
// console.log(manager.department); // ❌ Protected - not accessible
// console.log(manager.salary); // ❌ Private - not accessiblePython Access Modifiers (By Convention)
Python doesn't have true access modifiers—it uses naming conventions and trusts developers to respect them.
class Employee:
def __init__(
self,
name: str,
department: str,
salary: float,
employee_id: str
):
# Public: no prefix (accessible everywhere)
self.name = name
# Protected: single underscore (convention - subclass access)
self._department = department
# Private: double underscore (name mangling)
self.__salary = salary
# "Constants" by convention
self._EMPLOYEE_ID = employee_id
# Public method
def get_annual_salary(self) -> float:
return self.__salary * 12
# Protected method (by convention)
def _calculate_bonus(self) -> float:
return self.__salary * 0.1
# Private method (name mangling)
def __format_currency(self, amount: float) -> str:
return f"${amount:,.2f}"
def get_salary_report(self) -> str:
return f"Annual: {self.__format_currency(self.get_annual_salary())}"
class Manager(Employee):
def __init__(
self,
name: str,
department: str,
salary: float,
employee_id: str,
team_size: int
):
super().__init__(name, department, salary, employee_id)
self._team_size = team_size
def get_manager_info(self) -> str:
# ✅ Can access public
print(self.name)
# ✅ Can access protected (convention)
print(self._department)
bonus = self._calculate_bonus()
# ❌ Cannot easily access private (name mangling)
# print(self.__salary) # AttributeError
# ⚠️ Can still access with mangled name (not recommended!)
# print(self._Employee__salary) # Works but don't do this!
return f"{self.name} manages {self._team_size} people in {self._department}"
# Usage
manager = Manager("Alice", "Engineering", 100000, "EMP001", 10)
print(manager.name) # ✅ Public
print(manager._department) # ⚠️ Protected - accessible but discouraged
# print(manager.__salary) # ❌ AttributeError (name mangled)
print(manager._Employee__salary) # ⚠️ Technically works - never do this!Java Access Modifiers
public class Employee {
// Public: accessible everywhere
public String name;
// Protected: accessible in class, subclasses, and same package
protected String department;
// Private: accessible only in this class
private double salary;
// Final: cannot be changed after initialization
public final String employeeId;
public Employee(String name, String department, double salary, String employeeId) {
this.name = name;
this.department = department;
this.salary = salary;
this.employeeId = employeeId;
}
// Public method
public double getAnnualSalary() {
return salary * 12;
}
// Protected method
protected double calculateBonus() {
return salary * 0.1;
}
// Private method
private String formatCurrency(double amount) {
return String.format("$%,.2f", amount);
}
public String getSalaryReport() {
return "Annual: " + formatCurrency(getAnnualSalary());
}
}
public class Manager extends Employee {
private int teamSize;
public Manager(String name, String department, double salary,
String employeeId, int teamSize) {
super(name, department, salary, employeeId);
this.teamSize = teamSize;
}
public String getManagerInfo() {
// ✅ Can access public
System.out.println(this.name);
// ✅ Can access protected
System.out.println(this.department);
double bonus = calculateBonus();
// ❌ Cannot access private
// System.out.println(this.salary); // Compilation error!
return name + " manages " + teamSize + " people in " + department;
}
}
// Usage
Manager manager = new Manager("Alice", "Engineering", 100000, "EMP001", 10);
System.out.println(manager.name); // ✅ Public
System.out.println(manager.employeeId); // ✅ Final public
// System.out.println(manager.salary); // ❌ Private - compilation errorGetters and Setters: Controlled Access
Getters and setters (accessors and mutators) provide controlled access to private fields. They're not just boilerplate—they serve important purposes.
Why Use Getters and Setters?
- Validation: Ensure data integrity when setting values
- Computed Properties: Calculate values on-the-fly
- Lazy Loading: Defer expensive operations until needed
- Change Notification: React to state changes
- Debugging: Add logging or breakpoints
- Future Flexibility: Change implementation without changing interface
Example: Getters and Setters with Validation
TypeScript:
class User {
private _email: string;
private _age: number;
private _password: string;
constructor(email: string, age: number, password: string) {
// Use setters for validation during construction
this.email = email;
this.age = age;
this.password = password;
}
// Getter for email
get email(): string {
return this._email;
}
// Setter with validation
set email(value: string) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
throw new Error(`Invalid email format: ${value}`);
}
this._email = value.toLowerCase().trim();
}
// Getter for age
get age(): number {
return this._age;
}
// Setter with validation
set age(value: number) {
if (value < 0 || value > 150) {
throw new Error(`Invalid age: ${value}. Must be between 0 and 150.`);
}
this._age = Math.floor(value); // Ensure integer
}
// No getter for password - write-only
set password(value: string) {
if (value.length < 8) {
throw new Error("Password must be at least 8 characters");
}
if (!/[A-Z]/.test(value) || !/[0-9]/.test(value)) {
throw new Error("Password must contain uppercase letter and number");
}
// In real code: hash the password
this._password = this.hashPassword(value);
}
// Method to check password (not expose it)
checkPassword(attempt: string): boolean {
return this.hashPassword(attempt) === this._password;
}
private hashPassword(password: string): string {
// Simplified - use proper hashing in production!
return Buffer.from(password).toString("base64");
}
// Computed property - no backing field
get isAdult(): boolean {
return this._age >= 18;
}
// Getter with masking for privacy
get maskedEmail(): string {
const [local, domain] = this._email.split("@");
const masked = local.slice(0, 2) + "***" + local.slice(-1);
return `${masked}@${domain}`;
}
}
// Usage
const user = new User("John@Example.com", 25, "SecurePass123");
console.log(user.email); // john@example.com (normalized)
console.log(user.age); // 25
console.log(user.isAdult); // true (computed)
console.log(user.maskedEmail); // jo***n@example.com
// Validation in action
user.age = 30; // ✅ Valid
// user.age = -5; // ❌ Error: Invalid age
user.email = "new@email.com"; // ✅ Valid
// user.email = "invalid"; // ❌ Error: Invalid email format
// Password is write-only
// console.log(user.password); // ❌ Error: no getter
console.log(user.checkPassword("SecurePass123")); // true
console.log(user.checkPassword("wrong")); // falsePython:
import re
import hashlib
from typing import Optional
class User:
def __init__(self, email: str, age: int, password: str):
# Use setters for validation
self.email = email
self.age = age
self.password = password
@property
def email(self) -> str:
return self._email
@email.setter
def email(self, value: str) -> None:
email_regex = r'^[^\s@]+@[^\s@]+\.[^\s@]+$'
if not re.match(email_regex, value):
raise ValueError(f"Invalid email format: {value}")
self._email = value.lower().strip()
@property
def age(self) -> int:
return self._age
@age.setter
def age(self, value: int) -> None:
if not 0 <= value <= 150:
raise ValueError(f"Invalid age: {value}. Must be between 0 and 150.")
self._age = int(value)
# Write-only property for password (no getter)
@property
def password(self) -> None:
raise AttributeError("Password is write-only")
@password.setter
def password(self, value: str) -> None:
if len(value) < 8:
raise ValueError("Password must be at least 8 characters")
if not re.search(r'[A-Z]', value) or not re.search(r'[0-9]', value):
raise ValueError("Password must contain uppercase letter and number")
self._password_hash = self._hash_password(value)
def _hash_password(self, password: str) -> str:
# Use proper hashing in production (bcrypt, argon2)
return hashlib.sha256(password.encode()).hexdigest()
def check_password(self, attempt: str) -> bool:
return self._hash_password(attempt) == self._password_hash
# Computed property
@property
def is_adult(self) -> bool:
return self._age >= 18
# Masked property for privacy
@property
def masked_email(self) -> str:
local, domain = self._email.split("@")
masked = local[:2] + "***" + local[-1:]
return f"{masked}@{domain}"
# Usage
user = User("John@Example.com", 25, "SecurePass123")
print(user.email) # john@example.com
print(user.age) # 25
print(user.is_adult) # True
print(user.masked_email) # jo***n@example.com
user.age = 30 # ✅ Valid
# user.age = -5 # ❌ ValueError
# Password is write-only
# print(user.password) # ❌ AttributeError
print(user.check_password("SecurePass123")) # TrueJava:
import java.util.regex.Pattern;
import java.security.MessageDigest;
import java.util.Base64;
public class User {
private String email;
private int age;
private String passwordHash;
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$");
public User(String email, int age, String password) {
setEmail(email);
setAge(age);
setPassword(password);
}
// Getter
public String getEmail() {
return email;
}
// Setter with validation
public void setEmail(String value) {
if (!EMAIL_PATTERN.matcher(value).matches()) {
throw new IllegalArgumentException("Invalid email format: " + value);
}
this.email = value.toLowerCase().trim();
}
// Getter
public int getAge() {
return age;
}
// Setter with validation
public void setAge(int value) {
if (value < 0 || value > 150) {
throw new IllegalArgumentException(
"Invalid age: " + value + ". Must be between 0 and 150."
);
}
this.age = value;
}
// No getter for password - write-only
public void setPassword(String value) {
if (value.length() < 8) {
throw new IllegalArgumentException("Password must be at least 8 characters");
}
if (!value.matches(".*[A-Z].*") || !value.matches(".*[0-9].*")) {
throw new IllegalArgumentException(
"Password must contain uppercase letter and number"
);
}
this.passwordHash = hashPassword(value);
}
public boolean checkPassword(String attempt) {
return hashPassword(attempt).equals(this.passwordHash);
}
private String hashPassword(String password) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(password.getBytes());
return Base64.getEncoder().encodeToString(hash);
} catch (Exception e) {
throw new RuntimeException("Hashing failed", e);
}
}
// Computed property
public boolean isAdult() {
return this.age >= 18;
}
// Masked property
public String getMaskedEmail() {
String[] parts = this.email.split("@");
String local = parts[0];
String domain = parts[1];
String masked = local.substring(0, 2) + "***" +
local.substring(local.length() - 1);
return masked + "@" + domain;
}
}When NOT to Use Getters/Setters
Don't use getters and setters when:
- The field is truly public: Constants or simple data containers
- No validation needed: And none will ever be needed
- The class is a simple data transfer object (DTO)
// ❌ Unnecessary getters/setters
class Point {
private _x: number;
private _y: number;
get x(): number { return this._x; }
set x(value: number) { this._x = value; }
get y(): number { return this._y; }
set y(value: number) { this._y = value; }
}
// ✅ Just use public for simple data
class Point {
constructor(public x: number, public y: number) {}
}Immutable Objects
Immutable objects cannot be modified after creation. This provides thread safety, predictability, and prevents accidental state changes.
Benefits of Immutability
- Thread Safety: No synchronization needed for concurrent access
- Predictability: State never changes unexpectedly
- Hashable: Can be used as dictionary keys or set members
- Easy to Debug: State at any point is clear
- Functional Programming: Enables pure functions
Creating Immutable Objects
TypeScript:
// Immutable User class
class ImmutableUser {
readonly id: string;
readonly email: string;
readonly name: string;
readonly createdAt: Date;
constructor(id: string, email: string, name: string) {
this.id = id;
this.email = email;
this.name = name;
this.createdAt = new Date();
// Freeze to prevent any modifications
Object.freeze(this);
}
// "Setter" returns a new instance instead of modifying
withEmail(newEmail: string): ImmutableUser {
return new ImmutableUser(this.id, newEmail, this.name);
}
withName(newName: string): ImmutableUser {
return new ImmutableUser(this.id, this.email, newName);
}
// Immutable update with partial data
with(updates: Partial<Pick<ImmutableUser, "email" | "name">>): ImmutableUser {
return new ImmutableUser(
this.id,
updates.email ?? this.email,
updates.name ?? this.name
);
}
}
// Usage
const user1 = new ImmutableUser("1", "john@example.com", "John Doe");
console.log(user1.email); // john@example.com
// Create new user with different email
const user2 = user1.withEmail("john.doe@example.com");
console.log(user1.email); // john@example.com (unchanged)
console.log(user2.email); // john.doe@example.com (new instance)
// Attempting to modify throws error
// user1.email = "new@email.com"; // Error: Cannot assign to 'email' because it is a read-only property
// Chain updates
const user3 = user1
.withEmail("new@example.com")
.withName("John Updated");Python:
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
# Using frozen dataclass for immutability
@dataclass(frozen=True)
class ImmutableUser:
id: str
email: str
name: str
created_at: datetime = None
def __post_init__(self):
# Set created_at if not provided
if self.created_at is None:
# Workaround for frozen dataclass
object.__setattr__(self, 'created_at', datetime.now())
def with_email(self, new_email: str) -> "ImmutableUser":
"""Return new instance with updated email"""
return ImmutableUser(
id=self.id,
email=new_email,
name=self.name,
created_at=self.created_at
)
def with_name(self, new_name: str) -> "ImmutableUser":
"""Return new instance with updated name"""
return ImmutableUser(
id=self.id,
email=self.email,
name=new_name,
created_at=self.created_at
)
def with_updates(
self,
email: Optional[str] = None,
name: Optional[str] = None
) -> "ImmutableUser":
"""Return new instance with multiple updates"""
return ImmutableUser(
id=self.id,
email=email if email is not None else self.email,
name=name if name is not None else self.name,
created_at=self.created_at
)
# Usage
user1 = ImmutableUser("1", "john@example.com", "John Doe")
print(user1.email) # john@example.com
user2 = user1.with_email("john.doe@example.com")
print(user1.email) # john@example.com (unchanged)
print(user2.email) # john.doe@example.com (new instance)
# Attempting to modify raises error
# user1.email = "new@email.com" # FrozenInstanceError
# Can use as dictionary key (hashable)
users = {user1: "original", user2: "updated"}Java:
import java.time.Instant;
import java.util.Objects;
// Immutable class using record (Java 16+)
public record ImmutableUser(
String id,
String email,
String name,
Instant createdAt
) {
// Compact constructor for validation
public ImmutableUser {
Objects.requireNonNull(id, "id cannot be null");
Objects.requireNonNull(email, "email cannot be null");
Objects.requireNonNull(name, "name cannot be null");
if (createdAt == null) {
createdAt = Instant.now();
}
}
// Convenience constructor
public ImmutableUser(String id, String email, String name) {
this(id, email, name, Instant.now());
}
// "Setter" returns new instance
public ImmutableUser withEmail(String newEmail) {
return new ImmutableUser(id, newEmail, name, createdAt);
}
public ImmutableUser withName(String newName) {
return new ImmutableUser(id, email, newName, createdAt);
}
}
// Traditional immutable class (pre-Java 16)
public final class ImmutableUserTraditional {
private final String id;
private final String email;
private final String name;
private final Instant createdAt;
public ImmutableUserTraditional(String id, String email, String name) {
this.id = Objects.requireNonNull(id);
this.email = Objects.requireNonNull(email);
this.name = Objects.requireNonNull(name);
this.createdAt = Instant.now();
}
// Only getters, no setters
public String getId() { return id; }
public String getEmail() { return email; }
public String getName() { return name; }
public Instant getCreatedAt() { return createdAt; }
public ImmutableUserTraditional withEmail(String newEmail) {
ImmutableUserTraditional result = new ImmutableUserTraditional(id, newEmail, name);
// Copy immutable createdAt through reflection or builder pattern
return result;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ImmutableUserTraditional that = (ImmutableUserTraditional) o;
return Objects.equals(id, that.id) &&
Objects.equals(email, that.email) &&
Objects.equals(name, that.name);
}
@Override
public int hashCode() {
return Objects.hash(id, email, name);
}
}Defensive Copying for Mutable Fields
When an immutable class contains mutable fields (like arrays or dates), you must use defensive copying.
// ❌ BAD: Mutable field exposed
class User {
readonly roles: string[];
constructor(roles: string[]) {
this.roles = roles; // Same reference - can be modified externally!
}
}
const roles = ["user"];
const user = new User(roles);
roles.push("admin"); // Modifies user.roles too!
// ✅ GOOD: Defensive copy
class User {
private readonly _roles: readonly string[];
constructor(roles: string[]) {
// Defensive copy on input
this._roles = Object.freeze([...roles]);
}
get roles(): readonly string[] {
// Return readonly type (or defensive copy)
return this._roles;
}
// Return new instance with added role
withRole(role: string): User {
return new User([...this._roles, role]);
}
}
const roles = ["user"];
const user = new User(roles);
roles.push("admin");
console.log(user.roles); // ["user"] - unaffected
const adminUser = user.withRole("admin");
console.log(user.roles); // ["user"]
console.log(adminUser.roles); // ["user", "admin"]Real-World Example: Bank Account
Let's build a fully encapsulated, secure bank account class.
TypeScript:
interface Transaction {
id: string;
type: "deposit" | "withdrawal" | "transfer";
amount: number;
timestamp: Date;
balanceAfter: number;
description?: string;
}
class BankAccount {
// Private state - completely hidden
private balance: number;
private readonly transactions: Transaction[] = [];
private isLocked: boolean = false;
private failedAttempts: number = 0;
// Readonly properties
public readonly accountNumber: string;
public readonly accountHolder: string;
public readonly createdAt: Date;
// Configuration
private static readonly MAX_FAILED_ATTEMPTS = 3;
private static readonly DAILY_LIMIT = 10000;
constructor(accountNumber: string, accountHolder: string, initialDeposit: number = 0) {
this.validateAccountNumber(accountNumber);
this.validateAccountHolder(accountHolder);
this.accountNumber = accountNumber;
this.accountHolder = accountHolder;
this.createdAt = new Date();
this.balance = 0;
if (initialDeposit > 0) {
this.deposit(initialDeposit, "Initial deposit");
}
}
// Validation methods - private implementation details
private validateAccountNumber(accountNumber: string): void {
if (!/^\d{10}$/.test(accountNumber)) {
throw new Error("Account number must be exactly 10 digits");
}
}
private validateAccountHolder(name: string): void {
if (!name || name.trim().length < 2) {
throw new Error("Account holder name must be at least 2 characters");
}
}
private validateAmount(amount: number, operation: string): void {
if (amount <= 0) {
throw new Error(`${operation} amount must be positive`);
}
if (!Number.isFinite(amount)) {
throw new Error(`${operation} amount must be a valid number`);
}
}
private checkAccountStatus(): void {
if (this.isLocked) {
throw new Error("Account is locked. Please contact customer service.");
}
}
private generateTransactionId(): string {
return `TXN-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
private recordTransaction(
type: Transaction["type"],
amount: number,
description?: string
): Transaction {
const transaction: Transaction = {
id: this.generateTransactionId(),
type,
amount,
timestamp: new Date(),
balanceAfter: this.balance,
description,
};
this.transactions.push(transaction);
return transaction;
}
private getDailyTotal(type: Transaction["type"]): number {
const today = new Date();
today.setHours(0, 0, 0, 0);
return this.transactions
.filter(t => t.type === type && t.timestamp >= today)
.reduce((sum, t) => sum + t.amount, 0);
}
// Public API - controlled access
/**
* Get the current balance (read-only access)
*/
getBalance(): number {
this.checkAccountStatus();
return this.balance;
}
/**
* Get masked account number for display
*/
getMaskedAccountNumber(): string {
return "******" + this.accountNumber.slice(-4);
}
/**
* Deposit funds into the account
*/
deposit(amount: number, description?: string): Transaction {
this.checkAccountStatus();
this.validateAmount(amount, "Deposit");
this.balance += amount;
const transaction = this.recordTransaction("deposit", amount, description);
console.log(`Deposited $${amount.toFixed(2)}. New balance: $${this.balance.toFixed(2)}`);
return transaction;
}
/**
* Withdraw funds from the account
*/
withdraw(amount: number, description?: string): Transaction {
this.checkAccountStatus();
this.validateAmount(amount, "Withdrawal");
if (amount > this.balance) {
this.failedAttempts++;
if (this.failedAttempts >= BankAccount.MAX_FAILED_ATTEMPTS) {
this.isLocked = true;
throw new Error("Account locked due to multiple failed attempts");
}
throw new Error(`Insufficient funds. Available: $${this.balance.toFixed(2)}`);
}
const dailyTotal = this.getDailyTotal("withdrawal");
if (dailyTotal + amount > BankAccount.DAILY_LIMIT) {
throw new Error(
`Daily withdrawal limit exceeded. Remaining: $${(BankAccount.DAILY_LIMIT - dailyTotal).toFixed(2)}`
);
}
this.balance -= amount;
this.failedAttempts = 0; // Reset on successful operation
const transaction = this.recordTransaction("withdrawal", amount, description);
console.log(`Withdrew $${amount.toFixed(2)}. New balance: $${this.balance.toFixed(2)}`);
return transaction;
}
/**
* Transfer funds to another account
*/
transfer(toAccount: BankAccount, amount: number, description?: string): Transaction {
this.checkAccountStatus();
toAccount.checkAccountStatus();
this.validateAmount(amount, "Transfer");
if (amount > this.balance) {
throw new Error(`Insufficient funds for transfer. Available: $${this.balance.toFixed(2)}`);
}
// Perform transfer atomically
this.balance -= amount;
toAccount.balance += amount;
const transaction = this.recordTransaction(
"transfer",
amount,
description || `Transfer to ${toAccount.getMaskedAccountNumber()}`
);
toAccount.recordTransaction(
"transfer",
amount,
description || `Transfer from ${this.getMaskedAccountNumber()}`
);
console.log(
`Transferred $${amount.toFixed(2)} to ${toAccount.getMaskedAccountNumber()}`
);
return transaction;
}
/**
* Get transaction history (defensive copy)
*/
getTransactionHistory(): readonly Transaction[] {
this.checkAccountStatus();
// Return defensive copy - can't modify internal array
return [...this.transactions];
}
/**
* Get account summary
*/
getAccountSummary(): string {
return `
Account: ${this.getMaskedAccountNumber()}
Holder: ${this.accountHolder}
Balance: $${this.balance.toFixed(2)}
Status: ${this.isLocked ? "LOCKED" : "Active"}
Total Transactions: ${this.transactions.length}
Opened: ${this.createdAt.toLocaleDateString()}
`.trim();
}
}
// Usage
const account1 = new BankAccount("1234567890", "John Doe", 1000);
const account2 = new BankAccount("0987654321", "Jane Smith", 500);
console.log(account1.getAccountSummary());
// Account: ******7890
// Holder: John Doe
// Balance: $1000.00
// Status: Active
// Total Transactions: 1
// Opened: 2/5/2026
account1.deposit(500, "Salary");
account1.withdraw(200, "ATM withdrawal");
account1.transfer(account2, 300, "Rent payment");
console.log("\nTransaction History:");
account1.getTransactionHistory().forEach(t => {
console.log(`${t.type}: $${t.amount} - ${t.description || "N/A"}`);
});
// Protected data - cannot access directly
// account1.balance = 999999; // Error: Property 'balance' is private
// account1.transactions.push(...); // Error: Property 'transactions' is privateCommon Encapsulation Mistakes
Mistake 1: Exposing Internal Collections
// ❌ BAD: Returning internal collection reference
class ShoppingCart {
private items: string[] = [];
getItems(): string[] {
return this.items; // Returns the actual array!
}
}
const cart = new ShoppingCart();
cart.getItems().push("hacked item"); // Modifies internal state!
// ✅ GOOD: Return defensive copy
class ShoppingCart {
private items: string[] = [];
getItems(): readonly string[] {
return [...this.items]; // Returns copy
}
// Or use readonly type
getItemsReadonly(): readonly string[] {
return this.items; // TypeScript prevents modification
}
}Mistake 2: Leaking Mutable Objects
// ❌ BAD: Exposing mutable Date
class Event {
private startDate: Date;
getStartDate(): Date {
return this.startDate; // Returns same Date object
}
}
const event = new Event();
event.getStartDate().setFullYear(1900); // Modifies internal state!
// ✅ GOOD: Return copy of Date
class Event {
private startDate: Date;
getStartDate(): Date {
return new Date(this.startDate.getTime()); // Returns copy
}
}Mistake 3: Anemic Domain Model
An anemic domain model is when objects are just data containers with public getters/setters, and all logic is in separate "service" classes. This violates encapsulation.
// ❌ BAD: Anemic domain model
class Order {
public status: string;
public items: OrderItem[];
public total: number;
}
class OrderService {
// All logic is outside the Order class
calculateTotal(order: Order): void {
order.total = order.items.reduce((sum, item) => sum + item.price, 0);
}
ship(order: Order): void {
if (order.status !== "paid") {
throw new Error("Cannot ship unpaid order");
}
order.status = "shipped";
}
}
// ✅ GOOD: Rich domain model - behavior with data
class Order {
private _status: OrderStatus = "pending";
private readonly _items: OrderItem[] = [];
get status(): OrderStatus {
return this._status;
}
get items(): readonly OrderItem[] {
return this._items;
}
get total(): number {
return this._items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
addItem(item: OrderItem): void {
if (this._status !== "pending") {
throw new Error("Cannot modify non-pending order");
}
this._items.push(item);
}
pay(): void {
if (this._status !== "pending") {
throw new Error("Order is not pending");
}
if (this._items.length === 0) {
throw new Error("Cannot pay for empty order");
}
this._status = "paid";
}
ship(): void {
if (this._status !== "paid") {
throw new Error("Cannot ship unpaid order");
}
this._status = "shipped";
}
cancel(): void {
if (this._status === "shipped") {
throw new Error("Cannot cancel shipped order");
}
this._status = "cancelled";
}
}
type OrderStatus = "pending" | "paid" | "shipped" | "cancelled";
interface OrderItem {
productId: string;
name: string;
price: number;
quantity: number;
}Mistake 4: Breaking Encapsulation with Reflection
# ❌ BAD: Using name mangling workaround
class BankAccount:
def __init__(self, balance: float):
self.__balance = balance
account = BankAccount(1000)
# Bypassing encapsulation - NEVER do this!
account._BankAccount__balance = 999999
# ✅ GOOD: Respect encapsulation
# Use the public API as intended
account.deposit(999) # Use methods, not direct accessMistake 5: Getter Returns Internal Reference to Mutable State
// ❌ BAD: Config object can be modified
class AppConfig {
private settings: Record<string, any> = {
theme: "dark",
language: "en"
};
getSettings(): Record<string, any> {
return this.settings; // Returns reference!
}
}
const config = new AppConfig();
config.getSettings().theme = "hacked"; // Modifies internal state!
// ✅ GOOD: Return deep copy or frozen object
class AppConfig {
private settings: Record<string, any> = {
theme: "dark",
language: "en"
};
getSettings(): Readonly<Record<string, any>> {
return Object.freeze({ ...this.settings });
}
// Or provide specific getters
getTheme(): string {
return this.settings.theme;
}
setTheme(theme: string): void {
const validThemes = ["light", "dark", "system"];
if (!validThemes.includes(theme)) {
throw new Error(`Invalid theme: ${theme}`);
}
this.settings.theme = theme;
}
}Encapsulation Best Practices
1. Default to Private
Make everything private by default. Only expose what's truly necessary.
class Service {
// Start private, make public only if needed
private config: Config;
private cache: Map<string, any>;
private logger: Logger;
// Only expose what clients need
public process(data: Data): Result {
// Implementation uses private members
}
}2. Validate All Inputs
Never trust data coming into your object.
class Product {
private price: number;
setPrice(value: number): void {
if (value < 0) throw new Error("Price cannot be negative");
if (!Number.isFinite(value)) throw new Error("Price must be a valid number");
if (value > 1000000) throw new Error("Price exceeds maximum");
this.price = Math.round(value * 100) / 100; // Round to 2 decimals
}
}3. Use Immutability Where Possible
Immutable objects are inherently well-encapsulated.
// Prefer immutable value objects
class Money {
constructor(
public readonly amount: number,
public readonly currency: string
) {
Object.freeze(this);
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error("Cannot add different currencies");
}
return new Money(this.amount + other.amount, this.currency);
}
}4. Tell, Don't Ask
Instead of asking for data and operating on it externally, tell the object what to do.
// ❌ BAD: Ask for data, operate externally
if (account.getBalance() >= amount) {
account.setBalance(account.getBalance() - amount);
}
// ✅ GOOD: Tell the object what to do
account.withdraw(amount); // Object handles its own logic5. Keep Related Data and Behavior Together
// ❌ BAD: Data and behavior separated
class UserData {
email: string;
password: string;
}
class UserValidator {
validateEmail(user: UserData): boolean { /* ... */ }
validatePassword(user: UserData): boolean { /* ... */ }
}
// ✅ GOOD: Data and behavior together
class User {
private email: string;
private password: string;
setEmail(email: string): void {
if (!this.isValidEmail(email)) {
throw new Error("Invalid email");
}
this.email = email;
}
private isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
}Summary and Key Takeaways
Core Concepts
| Concept | Definition | Benefit |
|---|---|---|
| Encapsulation | Bundling data and methods together | Organization |
| Information Hiding | Restricting access to internals | Protection |
| Access Modifiers | public, private, protected | Visibility control |
| Getters/Setters | Controlled access with validation | Data integrity |
| Immutability | Objects that cannot change | Thread safety |
Access Modifier Guidelines
| Modifier | Use When |
|---|---|
| Private | Internal implementation details, helper methods |
| Protected | Subclasses need access, but not external code |
| Public | Part of the class's contract/API |
Encapsulation Checklist
✅ All fields are private by default
✅ Public API is minimal and intentional
✅ Input validation in setters
✅ Defensive copies for mutable objects
✅ No internal collections exposed
✅ Behavior lives with data (no anemic models)
What's Next?
Continue your OOP journey:
- Inheritance and Composition: Choose the right code reuse strategy
- Polymorphism and Interfaces: Flexible design patterns
- SOLID Principles: Clean OOP design
Practice Exercises
Exercise 1: Temperature Converter
Create an encapsulated Temperature class that:
- Stores temperature internally in Celsius (private)
- Provides getters for Celsius, Fahrenheit, and Kelvin
- Validates that temperature cannot be below absolute zero (-273.15°C)
- Is immutable (returns new instances for conversions)
Exercise 2: Password Manager Entry
Create a PasswordEntry class that:
- Stores website, username, and encrypted password
- Never exposes the raw password (only allows check/verify)
- Validates URL format and username requirements
- Tracks last accessed and last modified dates
- Provides masked display for the password
Exercise 3: Shopping Cart
Create an encapsulated ShoppingCart class that:
- Stores items privately with quantities
- Validates quantities (positive integers only)
- Provides read-only access to items (defensive copy)
- Calculates total with proper encapsulation
- Prevents modification of items after checkout
Questions or feedback? Feel free to reach out at contact@chanhle.dev or connect with me on X.
Happy coding! 🚀
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.