Understanding SOLID Principles: A Guide to Writing Better Code in TypeScript

In the world of software development, adhering to best practices can significantly enhance the quality and maintainability of your code. One such set of best practices is the SOLID principles. SOLID is an acronym for five design principles that help developers create more understandable, flexible, and maintainable software. In this article, we'll explore each principle, provide practical examples of both incorrect and correct implementations in TypeScript, and discuss the benefits of using these principles.

1. Single Responsibility Principle (SRP)

Definition

The Single Responsibility Principle states that a class or function should have only one reason to change. In other words, a class should have only one job or responsibility.

Incorrect Implementation

class Report {
  constructor(
    private title: string,
  ) {}

  generateReport(): void {
    // generate report
  }

  printReport(): void {
    console.log(`Printing report...\n${this.content}`);
  }

  saveReport(): void {
    const fs = require('fs');
    fs.writeFileSync(filePath, this.content);
  }
}

In this example, the Report class is responsible for generating, printing, and saving reports. If you need to change how reports are saved, you might inadvertently affect the report generation and printing logic, since you will need to change the whole class.

Correct Implementation

class ReportGenerator {
  generateReport(): void {
    // Generate report logic
  }
}

class ReportPrinter {
  printReport(report: string): void {
    // Print report logic
  }
}

class ReportSaver {
  saveReport(report: string): void {
    // Save report logic
  }
}

In this improved design, each class has a single responsibility: generating, printing, or saving reports. This makes each class easier to modify and understand.

Benefits

  • Improved Code Maintainability: Changes in one part of the system are less likely to impact other parts.

  • Enhanced Readability: Code is more straightforward and easier to understand.

  • Simpler Testing: Each class can be tested independently

2. Open/Closed Principle (OCP)

Definition

The Open/Closed Principle states that software entities should be open for extension but closed for modification. This means you should be able to add new functionality without changing existing code.

Incorrect Implementation

class PaymentProcessor {
  processPayment(type: string, amount: number) {
    if (type === 'creditCard') {
      // logic for credit card payments
    } else if (type === 'paypal') {
      // logic for PayPal payments
    } else {
      throw new Error('Unknown payment type');
    }
  }
}

If we need to add a new payment method, we would have to modify the existing code, which violates OCP.

Correct Implementation

interface PaymentMethod {
  process(amount: number): void;
}

// Implement different payment methods
class CreditCardPayment implements PaymentMethod {
  process(amount: number): void {
    // logic for credit card payments
  }
}

class PayPalPayment implements PaymentMethod {
  process(amount: number): void {
    //  logic for PayPal payments
  }
}

// New payment method
class BitcoinPayment implements PaymentMethod {
  process(amount: number): void {
    // logic for Bitcoin payments
  }
}

// Payment processor that uses the PaymentMethod interface
class PaymentProcessor {
  private paymentMethod: PaymentMethod;

  constructor(paymentMethod: PaymentMethod) {
    this.paymentMethod = paymentMethod;
  }

  processPayment(amount: number) {
    this.paymentMethod.process(amount);
  }
}

In this approach, we don’t modify the existing PaymentProcessor class when adding a Bitcoin payment method. Instead, we use an interface to extend functionality.

Benefits

  • Reduced Risk of Bugs: The existing code has not been altered, so there's less of a risk of introducing new bugs.

  • Ease of Extension: New features or functionality can be added with minimal changes.

  • Improved Code Organization: Code adheres to a more predictable and scalable structure.

3. Liskov Substitution Principle (LSP)

Definition

The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of a subclass without altering the correctness of the program.

Incorrect Implementation

// Base class
class Vehicle {
  fuelCapacity: number;
  fuelConsumptionRate: number;

  constructor(fuelCapacity: number, fuelConsumptionRate: number) {
    this.fuelCapacity = fuelCapacity;
    this.fuelConsumptionRate = fuelConsumptionRate;
  }

  calculateFuelEfficiency(): number {
    return this.fuelCapacity / this.fuelConsumptionRate;
  }
}

// Incorrect subclass representing an electric vehicle
class ElectricVehicle extends Vehicle {
  batteryCapacity: number;

  constructor(fuelCapacity: number, fuelConsumptionRate: number, batteryCapacity: number) {
    super(fuelCapacity, fuelConsumptionRate);
    this.batteryCapacity = batteryCapacity;
  }

  calculateFuelEfficiency(): number {
    throw new Error("Electric vehicles do not use fuel, cannot calculate fuel efficiency");
  }
}

Substituting a Vehicle object with an ElectricVehicle object in the codebase can break the expected behavior of the calculateFuelEfficiency method, as it will now throw an error instead of returning a numeric value as intended.

Correct Implementation

abstract class Vehicle {
  abstract getPerformance(): number;

// I can have common methods here
}

// Subclass for fuel-based vehicles
class FuelVehicle extends Vehicle {
  private fuelCapacity: number;
  private fuelConsumptionRate: number;

  constructor(fuelCapacity: number, fuelConsumptionRate: number) {
    super();
    this.fuelCapacity = fuelCapacity;
    this.fuelConsumptionRate = fuelConsumptionRate;
  }

  getPerformance(): number {
    return this.fuelCapacity / this.fuelConsumptionRate;
  }
}

// Subclass for electric vehicles
class ElectricVehicle extends Vehicle {
  private batteryCapacity: number;

  constructor(batteryCapacity: number) {
    super();
    this.batteryCapacity = batteryCapacity;
  }

  getPerformance(): number {
    return this.batteryCapacity * 5;
  }
}

In this example, FuelVehicle and ElectricVehicle both adhere to the getPerformance method, but each implements it differently according to their capabilities.

Benefits

  • Enhanced Reliability: Subtypes can be used interchangeably without altering program behavior.

  • Increased Flexibility: The system can be extended with new subclasses without affecting existing functionality.

  • Clearer Code Design: Ensures that subclasses honor the contract established by their superclasses.

4. Interface Segregation Principle (ISP)

Definition

The Interface Segregation Principle states that no client should be forced to depend on methods it does not use. Interfaces should be designed to be specific to the needs of the client.

Incorrect Implementation

interface DocumentProcessor {
    read(): void;
    write(): void;
    print(): void;
    format(): void;
    calculate(): void;
}

class TextFileProcessor implements DocumentProcessor {
    read() {
        console.log("Reading text file...");
    }

    write() {
        console.log("Writing to text file...");
    }

    print() {
        console.log("Printing text file...");
    }

    format() {
        // Text files typically don't need formatting
        throw new Error("Not supported");
    }

    calculate() {
        // Text files typically don't need calculations
        throw new Error("Not supported");
    }
}

In this case, the TextFileProcessor class must implement all methods from the interface because other document types require additional methods. However, two of these methods are not necessary for text documents. To adhere to the Interface Segregation Principle, we should refactor the design by breaking the class into smaller, more specific interfaces.

Correct Implementation

interface Readable {
    read(): void;
}

interface Writable {
    write(): void;
}

interface Printable {
    print(): void;
}

interface Formattable {
    format(): void;
}

interface Calculable {
    calculate(): void;
}

class TextFileProcessor implements Readable, Writable, Printable {
    read() {
        console.log("Reading text file...");
    }

    write() {
        console.log("Writing to text file...");
    }

    print() {
        console.log("Printing text file...");
    }
}

By separating concerns into different interfaces, we ensure that each class only implements what it truly needs.

Benefits

  • Reduced Impact of Changes: Changes to one interface do not affect clients who don’t use it.

  • Increased Code Reusability: Interfaces are more versatile and can be used in various contexts.

  • Improved Code Organization: A clearer separation of concerns leads to more maintainable code.

5. Dependency Inversion Principle (DIP)

Definition

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. Additionally, abstractions should not depend on details. Details should depend on abstractions.

Incorrect Implementation

import orderRepository from '../order-repository.ts';

class CreditCardPayment {
    processPayment(orderId: string, amount: number): void {
        // Retrieve the order and process payment
        const order = orderRepository.getOrderById(orderId);
        if (order) {
            // Logic to process payment
        }
    }
}

In this example, CreditCardPayment directly depends on orderRepository, making it hard to change or test.

Correct Implementation

interface IOrderRepository {
    saveOrder(order: Order): void;
    getOrderById(id: string): Order | null;
}

class CreditCardPayment {
    private orderRepository: IOrderRepository;

    constructor(orderRepository: IOrderRepository) {
        this.orderRepository = orderRepository;
    }

    processPayment(orderId: string, amount: number): void {
        const order = this.orderRepository.getOrderById(orderId);
        // logic to process payment..
    }
}

Here, CreditCardPayment depends on an abstraction IOrderRepository rather than a specific implementation. This allows for more flexibility and easier testing.

Benefits

  • Decoupled Code: High-level modules are less affected by changes in low-level modules.

  • Enhanced Flexibility: Components can be easily replaced or modified without altering the overall system.

  • Easier Testing: Dependencies can be easily mocked or stubbed for testing.

Conclusion

Adhering to the SOLID principles can greatly improve the design and maintainability of your software. By ensuring that your code adheres to these principles, you can create more flexible, understandable, and reliable systems. Each principle offers specific benefits that contribute to the overall quality of your code, making it easier to manage and evolve over time.

0
Subscribe to my newsletter

Read articles from Luis Gustavo Ganimi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Luis Gustavo Ganimi
Luis Gustavo Ganimi