Now that you understand the key language mechanisms, you can formalize the pillars of Object-Oriented Programming that guide the creation of systems that are better organized, reusable, and scalable.

Inheritance – Superclass and Subclass

Inheritance is a mechanism that allows a class to derive characteristics from another class. When a class B inherits from a class A, it means that B automatically acquires the attributes and methods of A without needing to redefine them.

You can visualize this relationship as a parent-child structure, where A is the superclass (base/parent class) and B is the subclass (derived/child class). A subclass can use inherited resources, add new behaviors, or override superclass methods to address specific needs.

We’ve already discussed inheritance when learning about abstract classes, but inheritance can also be applied to concrete classes. This allows for code reuse and behavior specialization.

// BankAccount is now a regular class where you define attributes and methods
// that will be reused by the child class CurrentAccount
class BankAccount {
  balance: number = 0;

  constructor(initialBalance: number) {
    this.balance = initialBalance;
  }

  deposit(amount: number): void {
    this.balance += amount;
  }

  withdraw(amount: number): void {
    if (amount <= this.balance) {
      this.balance -= amount;
    }
  }
}

// CurrentAccount is a subclass of BankAccount, meaning
// it inherits its attributes and methods.
class CurrentAccount extends BankAccount {
  overdraftLimit: number; // new attribute exclusive to CurrentAccount

  // When specifying a constructor method for a subclass,
  // we need to call another special method, "super".
  // This method calls the superclass (BankAccount) constructor to ensure
  // it is initialized before creating the CurrentAccount object itself.
  constructor(initialBalance: number, overdraftLimit: number) {
    super(initialBalance); // Must match the superclass constructor method signature
    this.overdraftLimit = overdraftLimit;
  }

  // Even though the withdraw method already exists in the superclass (BankAccount),
  // it is overridden here. This means every time a CurrentAccount
  // object calls the withdraw method, this implementation will be used,
  // ignoring the superclass method.
  override withdraw(amount: number): void {
    const totalAvailable = this.balance + this.overdraftLimit;
    if (amount > 0 && amount <= totalAvailable) {
      this.balance -= amount;
    }
  }
}

// Creating a CurrentAccount with an initial balance of $0.00
// and an overdraft limit of $100.
const currentAccount = new CurrentAccount(0, 100);

// Making a $200 deposit by calling the deposit method
// In this case, the method from BankAccount will be invoked
// since deposit was not overridden in CurrentAccount
currentAccount.deposit(200); // balance: 200

// Withdrawing $250 by calling the withdraw method
// In this case, the method from CurrentAccount will be invoked
// as it has been overridden in its definition
currentAccount.withdraw(250); // balance: -50

Polymorphism

Polymorphism is a concept that often creates confusion in Object-Oriented Programming. But in practice, it is merely a natural consequence of using interfaces and inheritance.

The term polymorphism originates from Greek and means "many forms" (poly = many, morphos = forms). This concept allows objects from different classes to respond to the same method call but with distinct implementations, making code more flexible and reusable.

To clarify this concept, let's consider a practical example. Suppose you have a function named sendMoney, responsible for processing a financial transaction, transferring a certain amount from account A to account B. The only requirement is that both accounts follow a common contract, ensuring the methods withdraw and deposit are available.

// BankAccount could be an interface, a concrete class,
// or an abstract class. For the sendMoney function, the specific implementation
// does not matter—only that BankAccount includes withdraw and deposit methods.
function sendMoney(
  sender: BankAccount,
  receiver: BankAccount,
  amount: number
) {
  sender.withdraw(amount);
  receiver.deposit(amount);
}

const lucasAccount = new CurrentAccount(500, 200);
const mariaAccount = new SavingsAccount(300);

// transferring $100 from Lucas to Maria
sendMoney(lucasAccount, mariaAccount, 100);

Polymorphic Methods:

The withdraw and deposit methods are called within the sendMoney function without requiring the function to know whether it is dealing with a CurrentAccount or SavingsAccount. Each class implements withdraw according to its own rules, demonstrating the concept of polymorphism.

Decoupling:

The sendMoney function does not depend on the specific type of bank account. Any class that extends BankAccount (if it's a class) or implements BankAccount (if it's an interface) can be used without requiring modifications to the sendMoney function.

With this approach, you ensure flexibility and code reusability, as new account types can be introduced without affecting the functionality of sendMoney.

Encapsulation

Encapsulation is one of the fundamental principles of OOP, but its concept can be applied to any programming paradigm. It involves hiding the internal implementation details of a module, class, function, or any other software component, exposing only what is necessary for external use. This improves code security, maintainability, and modularity by preventing unauthorized access and ensuring controlled interactions.

Access Modifiers – publicprivate, and protected

In OOP, encapsulation is essential for controlling the visibility and access to methods and attributes within a class. In TypeScript, this is achieved using access modifiers, which are defined by the keywords publicprotected, and private.