Skip to main content

02 - Encapsulation in OOP: Protecting and Hiding Data

What is Encapsulation in Object-Oriented Programming Encapsulation is one of the fundamental pillars of object-oriented programming, a concept that protects...

What do you think about this article?

At a Glance

What is Encapsulation in Object-Oriented Programming Encapsulation is one of the fundamental pillars of object-oriented programming, a concept that protects...

What is Encapsulation in Object-Oriented Programming

Encapsulation is one of the fundamental pillars of object-oriented programming (OOP) and represents the principle of hiding implementation details of a class, exposing only what is necessary through a well-defined public interface. This concept is crucial for creating maintainable, secure, and modular software.

🎯 Goals of Encapsulation

  • Information Hiding: Hide implementation details
  • Data Protection: Prevent unauthorized access and modifications
  • Flexibility: Change implementation without impacting external code
  • Maintainability: Reduce coupling between components

Access Modifiers in TypeScript

TypeScript offers three access modifiers to implement encapsulation: private, protected, and public. Choosing the right modifier determines who can access class members.

1. Private: Internal Access Only

private members are accessible only within the class that defines them. This is the most restrictive level of encapsulation and should be the default for all internal class data.

TypeScript - Private Members
class BankAccount {{ '{' }}
  // Private properties - internal access only
  private balance: number;
  private accountNumber: string;
  private transactionHistory: Transaction[] = [];
  private isLocked: boolean = false;

  constructor(accountNumber: string, initialBalance: number) {{ '{' }}
    this.accountNumber = accountNumber;
    this.balance = initialBalance;
    this.logTransaction('Account created', initialBalance);
  {{ '}' }}

  // Public method with validation
  public deposit(amount: number): void {{ '{' }}
    if (this.isLocked) {{ '{' }}
      throw new Error('Account is locked');
    {{ '}' }}

    if (amount <= 0) {{ '{' }}
      throw new Error('Amount must be positive');
    {{ '}' }}

    this.balance += amount;
    this.logTransaction('Deposit', amount);
  {{ '}' }}

  public withdraw(amount: number): boolean {{ '{' }}
    if (this.isLocked) {{ '{' }}
      throw new Error('Account is locked');
    {{ '}' }}

    if (amount > this.balance) {{ '{' }}
      console.log('Insufficient funds');
      return false;
    {{ '}' }}

    if (amount <= 0) {{ '{' }}
      throw new Error('Amount must be positive');
    {{ '}' }}

    this.balance -= amount;
    this.logTransaction('Withdrawal', amount);
    return true;
  {{ '}' }}

  // Public getter - read-only access
  public getBalance(): number {{ '{' }}
    return this.balance;
  {{ '}' }}

  public getAccountNumber(): string {{ '{' }}
    return this.accountNumber;
  {{ '}' }}

  // Private method - internal helper
  private logTransaction(type: string, amount: number): void {{ '{' }}
    const transaction: Transaction = {{ '{' }}
      id: Date.now().toString(),
      type,
      amount,
      timestamp: new Date(),
      balanceAfter: this.balance
    {{ '}' }};
    this.transactionHistory.push(transaction);
  {{ '}' }}

  // Method to get copy of history (not the original array!)
  public getTransactionHistory(): readonly Transaction[] {{ '{' }}
    return Object.freeze([...this.transactionHistory]);
  {{ '}' }}

  // Administrative methods
  public lockAccount(): void {{ '{' }}
    this.isLocked = true;
  {{ '}' }}

  public unlockAccount(adminCode: string): void {{ '{' }}
    if (this.verifyAdminCode(adminCode)) {{ '{' }}
      this.isLocked = false;
    {{ '}' }}
  {{ '}' }}

  private verifyAdminCode(code: string): boolean {{ '{' }}
    // Secure verification logic
    return code === 'ADMIN_SECRET_CODE';
  {{ '}' }}
{{ '}' }}

interface Transaction {{ '{' }}
  id: string;
  type: string;
  amount: number;
  timestamp: Date;
  balanceAfter: number;
{{ '}' }}

// Usage
const account = new BankAccount('IT60X0542811101000000123456', 1000);

account.deposit(500);    // ✅ OK
console.log(account.getBalance()); // ✅ OK - 1500

// account.balance = 999999;  // ❌ ERROR: balance is private
// account.logTransaction();  // ❌ ERROR: logTransaction is private

✨ Advantages of Private Members

  • Total Control: Only the class can modify internal state
  • Centralized Validation: All modifications go through validated public methods
  • Safe Refactoring: Can change private implementation without breaking external code
  • Easy Debugging: Know exactly where state is modified

2. Protected: Access for Subclasses

protected members are accessible from the class and its subclasses. Useful when you want to allow extension but maintain encapsulation from the outside.

TypeScript - Protected Members
abstract class Vehicle {{ '{' }}
  protected engineStatus: 'on' | 'off' = 'off';
  protected currentSpeed: number = 0;
  protected readonly maxSpeed: number;

  constructor(maxSpeed: number) {{ '{' }}
    this.maxSpeed = maxSpeed;
  {{ '}' }}

  // Protected method - usable by subclasses
  protected startEngine(): void {{ '{' }}
    if (this.engineStatus === 'off') {{ '{' }}
      this.engineStatus = 'on';
      console.log('Engine started');
    {{ '}' }}
  {{ '}' }}

  protected stopEngine(): void {{ '{' }}
    if (this.currentSpeed === 0) {{ '{' }}
      this.engineStatus = 'off';
      console.log('Engine stopped');
    {{ '}' }} else {{ '{' }}
      console.log('Cannot stop engine while moving');
    {{ '}' }}
  {{ '}' }}

  // Public method
  public getSpeed(): number {{ '{' }}
    return this.currentSpeed;
  {{ '}' }}

  // Abstract method - must be implemented
  abstract accelerate(amount: number): void;
{{ '}' }}

class Car extends Vehicle {{ '{' }}
  private gear: number = 1;

  constructor() {{ '{' }}
    super(200); // maxSpeed for car
  {{ '}' }}

  accelerate(amount: number): void {{ '{' }}
    // Can access protected members of parent
    if (this.engineStatus === 'off') {{ '{' }}
      this.startEngine(); // ✅ OK - protected method
    {{ '}' }}

    if (this.currentSpeed + amount <= this.maxSpeed) {{ '{' }}
      this.currentSpeed += amount;
      this.adjustGear();
    {{ '}' }}
  {{ '}' }}

  brake(amount: number): void {{ '{' }}
    this.currentSpeed = Math.max(0, this.currentSpeed - amount);

    if (this.currentSpeed === 0) {{ '{' }}
      this.stopEngine(); // ✅ OK - protected method
    {{ '}' }}
  {{ '}' }}

  private adjustGear(): void {{ '{' }}
    // Private gear shifting logic
    if (this.currentSpeed > 80) this.gear = 5;
    else if (this.currentSpeed > 60) this.gear = 4;
    else if (this.currentSpeed > 40) this.gear = 3;
    else if (this.currentSpeed > 20) this.gear = 2;
    else this.gear = 1;
  {{ '}' }}
{{ '}' }}

// Usage
const car = new Car();
car.accelerate(50);
console.log(car.getSpeed()); // 50

// car.startEngine();  // ❌ ERROR: startEngine is protected
// car.engineStatus;   // ❌ ERROR: engineStatus is protected

3. Public: Free Access

public members are accessible from anywhere in the code. These constitute the class's public interface and should be minimized and well-documented.

⚠️ Principle of Least Privilege

Always start with private and only relax when necessary. It's easier to make something public later than to make private something that was public (breaking change).

  • Private by default
  • Protected only if necessary for subclasses
  • Public only for essential interface

Getters and Setters: Controlled Access

Getters and setters in TypeScript offer an elegant way to control property access, allowing validation and custom logic.

TypeScript - Getters and Setters
class User {{ '{' }}
  private _email: string;
  private _age: number;
  private _passwordHash: string;

  constructor(email: string, age: number, password: string) {{ '{' }}
    this._email = email;
    this._age = age;
    this._passwordHash = this.hashPassword(password);
  {{ '}' }}

  // Getter - read-only access with transformation
  get email(): string {{ '{' }}
    return this._email.toLowerCase();
  {{ '}' }}

  // Setter with validation
  set email(value: string) {{ '{' }}
    if (!this.isValidEmail(value)) {{ '{' }}
      throw new Error('Invalid email format');
    {{ '}' }}
    this._email = value;
  {{ '}' }}

  // Getter
  get age(): number {{ '{' }}
    return this._age;
  {{ '}' }}

  // Setter with validation
  set age(value: number) {{ '{' }}
    if (value < 0 || value > 150) {{ '{' }}
      throw new Error('Invalid age');
    {{ '}' }}
    this._age = value;
  {{ '}' }}

  // Computed property - getter only
  get isAdult(): boolean {{ '{' }}
    return this._age >= 18;
  {{ '}' }}

  get ageGroup(): string {{ '{' }}
    if (this._age < 18) return 'minor';
    if (this._age < 65) return 'adult';
    return 'senior';
  {{ '}' }}

  // Public method to change password
  changePassword(oldPassword: string, newPassword: string): boolean {{ '{' }}
    if (!this.verifyPassword(oldPassword)) {{ '{' }}
      return false;
    {{ '}' }}

    if (newPassword.length < 8) {{ '{' }}
      throw new Error('Password must be at least 8 characters');
    {{ '}' }}

    this._passwordHash = this.hashPassword(newPassword);
    return true;
  {{ '}' }}

  // Private methods - helpers
  private isValidEmail(email: string): boolean {{ '{' }}
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  {{ '}' }}

  private hashPassword(password: string): string {{ '{' }}
    // Hash simulation (use bcrypt in production!)
    return `hash_${{ '{' }}password.split('').reverse().join(''){{ '}' }}`;
  {{ '}' }}

  private verifyPassword(password: string): boolean {{ '{' }}
    return this.hashPassword(password) === this._passwordHash;
  {{ '}' }}
{{ '}' }}

// Usage
const user = new User('john@example.com', 25, 'password123');

console.log(user.email);     // "john@example.com"
console.log(user.isAdult);   // true
console.log(user.ageGroup);  // "adult"

user.age = 30;               // ✅ OK - passes validation
// user.age = -5;            // ❌ Error: Invalid age
// user.email = 'invalid';   // ❌ Error: Invalid email format

user.changePassword('password123', 'newSecurePass123'); // ✅ OK

// user._passwordHash;       // ❌ ERROR: _passwordHash is private

✅ When to Use Getters/Setters:

  • Data validation needed
  • Value transformation
  • Computed properties
  • Logging modifications
  • Lazy initialization

❌ When NOT to Use Them:

  • Simple access without logic
  • Just to "look OOP"
  • Heavy computations in getter
  • Side effects in getter

Readonly: Immutability

The readonly modifier ensures that a property can only be assigned during initialization (constructor) and cannot be modified afterwards.

TypeScript - Readonly
class Configuration {{ '{' }}
  public readonly appName: string;
  public readonly version: string;
  private readonly apiKey: string;
  public readonly createdAt: Date;

  constructor(appName: string, version: string, apiKey: string) {{ '{' }}
    this.appName = appName;
    this.version = version;
    this.apiKey = apiKey;
    this.createdAt = new Date(); // Can only be assigned here
  {{ '}' }}

  // Shorthand syntax in constructor
  constructor(
    public readonly appName: string,
    public readonly version: string,
    private readonly apiKey: string
  ) {{ '{' }}
    // TypeScript automatically creates properties
  {{ '}' }}

  getApiKey(): string {{ '{' }}
    return this.apiKey;
  {{ '}' }}
{{ '}' }}

const config = new Configuration('MyApp', '1.0.0', 'secret_key');
console.log(config.appName);  // "MyApp"
// config.appName = 'NewName'; // ❌ ERROR: readonly property

Encapsulation Best Practices

🎯 Professional Guidelines

1. Tell, Don't Ask

Instead of asking for state and then deciding, tell the object what to do:

❌ Bad (Ask):
if (account.getBalance() > amount) {{ '{' }}
  account.setBalance(
    account.getBalance() - amount
  );
{{ '}' }}
✅ Good (Tell):
account.withdraw(amount);

2. Law of Demeter (LoD)

Don't talk to strangers - an object should only call:

  • Its own methods
  • Parameters passed to the method
  • Objects it creates
  • Direct components
❌ LoD Violation:
user.getAddress()
    .getCity()
    .getZipCode()
✅ Respects LoD:
user.getCityZipCode()

3. Immutability When Possible

Favor immutable objects - easier to reason about and thread-safe:

class Point {{ '{' }}
  constructor(
    public readonly x: number,
    public readonly y: number
  ) {{ '{' }}{{ '}' }}

  // Instead of modifying, return new object
  move(dx: number, dy: number): Point {{ '{' }}
    return new Point(this.x + dx, this.y + dy);
  {{ '}' }}
{{ '}' }}

Common Mistakes to Avoid

⚠️ Encapsulation Anti-Patterns

1. Useless Getter/Setter (JavaBeans Anti-Pattern)
// ❌ Bad - adds no value
class Product {{ '{' }}
  private name: string;

  getName(): string {{ '{' }} return this.name; {{ '}' }}
  setName(name: string) {{ '{' }} this.name = name; {{ '}' }}
{{ '}' }}

// ✅ Better - public property if no logic needed
class Product {{ '{' }}
  public name: string;
{{ '}' }}
2. Exposing Mutable Collections
// ❌ Bad - array can be modified externally
getItems(): Item[] {{ '{' }}
  return this.items;
{{ '}' }}

// ✅ Good - return immutable copy
getItems(): readonly Item[] {{ '{' }}
  return Object.freeze([...this.items]);
{{ '}' }}
3. Too Many Getters/Setters

If you have 20 getters and setters, the class probably does too much (violates Single Responsibility).

Conclusion

Encapsulation is fundamental for creating quality OOP code. By hiding implementation details and exposing only well-defined interfaces, you achieve more secure, maintainable, and flexible code. Remember: private by default, and only relax when strictly necessary.

🎯 Key Points

  • Use private for all internal details
  • Use protected only for controlled extension
  • Keep public interface minimal and stable
  • Always validate input data
  • Prefer getters/setters when logic is needed
  • Use readonly for data that shouldn't change
  • Apply "Tell, Don't Ask" and Law of Demeter

📚 Next in the OOP Series

In the next article we'll explore Inheritance and Polymorphism, seeing how classes can extend and how to leverage polymorphism for flexible and extensible code.

Frequently asked questions

What is encapsulation in object-oriented programming?

Encapsulation is the principle of hiding implementation details of a class, exposing only what is necessary through a well-defined public interface.

How many access modifiers does TypeScript offer to implement encapsulation?

TypeScript offers three access modifiers: private, protected, and public.

What is the purpose of using getters and setters in TypeScript?

Getters and setters in TypeScript allow for controlled property access, enabling validation and custom logic.

Why should properties be made private by default in encapsulation?

Properties should be made private by default because it's easier to make something public later than to make private something that was public (breaking change).

Sources and further reading

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects