Skip to content

Understanding SOLID Principles with TypeScript Examples

Posted on:July 11, 2023

Table of contents

Open Table of contents

Single Responsibility Principle (SRP)

Let’s illustrate the Single Responsibility Principle with a TypeScript example.

// Student class violating Single Responsibility Principle
class Student {
  private details: string;

  constructor(details: string) {
    this.details = details;
  }

  enrollInCourse(course: string) {
    // logic for enrolling in a course
  }

  saveToDatabase() {
    // logic for saving to the database
  }

  sendEmail() {
    // logic for sending email
  }
}

The Single Responsibility Principle (SRP) states that a class should have only one reason to change. In the context of TypeScript, this means that each class should handle a single responsibility, making the code more maintainable and less prone to bugs.

Consider a scenario where we have a Student class that manages student details, course enrollment, database saving, and email sending. This violates the SRP as the class has multiple responsibilities, and any change may affect other functionalities.

To adhere to SRP, we refactor the code by creating separate classes for each responsibility: StudentDetails, CourseEnrollment, DatabaseSaver, and EmailSender. Now, each class has a single responsibility, promoting cleaner code and easier maintenance.

// Refactored classes following Single Responsibility Principle
class StudentDetails {
  private details: string;

  constructor(details: string) {
    this.details = details;
  }
}

class CourseEnrollment {
  enroll(student: StudentDetails, course: string) {
    // logic for enrolling in a course
  }
}

class DatabaseSaver {
  save(student: StudentDetails) {
    // logic for saving to the database
  }
}

class EmailSender {
  send(student: StudentDetails) {
    // logic for sending email
  }
}

The Single Responsibility Principle urges us to design classes with a single, well-defined responsibility. In our TypeScript example, we’ll refactor a class handling student details, course enrollment, database saving, and email sending into separate classes, each with a specific responsibility.

Open-Closed Principle with TypeScript

The Open-Closed Principle (OCP) emphasizes that a class should be open for extension but closed for modification. This means that you should be able to add new functionality to a class without changing its existing code.

In TypeScript, you can demonstrate the Open-Closed Principle using interfaces and inheritance.

// Define an interface for a shape
interface Shape {
  calculateArea(): number;
}

// Implement a class for a rectangle that implements the Shape interface
class Rectangle implements Shape {
  constructor(
    public width: number,
    public height: number
  ) {}

  calculateArea(): number {
    return this.width * this.height;
  }
}

// Implement a class for a circle that also implements the Shape interface
class Circle implements Shape {
  constructor(public radius: number) {}

  calculateArea(): number {
    return Math.PI * this.radius * this.radius;
  }
}

// Function that takes an array of shapes and calculates the total area
function calculateTotalArea(shapes: Shape[]): number {
  let totalArea = 0;

  for (const shape of shapes) {
    totalArea += shape.calculateArea();
  }

  return totalArea;
}

// Example usage
const rectangle = new Rectangle(5, 10);
const circle = new Circle(7);

const totalArea = calculateTotalArea([rectangle, circle]);
console.log(`Total Area: ${totalArea}`);

In this example, the Shape interface defines a contract that any shape must implement a calculateArea method. The Rectangle and Circle classes implement this interface, providing their own specific implementations of the calculateArea method.

The calculateTotalArea function takes an array of shapes, and it can work with any shape that implements the Shape interface. If you want to add a new shape, you can create a new class that implements the Shape interface without modifying the existing code.

This demonstrates the Open-Closed Principle because you can introduce new shapes without changing the calculateTotalArea function or the existing shape classes.

Liskov Substitution Principle with TypeScript

The Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. In TypeScript, this means that a child class should be able to do everything the parent class can.

In the example, we have an Animal class with a makeSound method. The Dog and Cat classes extend Animal and provide their own implementations of makeSound. The animalSound function adheres to LSP by accepting an Animal parameter and calling its makeSound method, demonstrating that both Dog and Cat can be used interchangeably.

class Animal {
  makeSound(): string {
    return "Generic animal sound";
  }
}

class Dog extends Animal {
  makeSound(): string {
    return "Bark";
  }
}

class Cat extends Animal {
  makeSound(): string {
    return "Meow";
  }
}

// Function using Liskov Substitution Principle
function animalSound(animal: Animal): string {
  return animal.makeSound();
}

// Using Liskov Substitution Principle
const dog = new Dog();
const cat = new Cat();

console.log(animalSound(dog)); // Output: Bark
console.log(animalSound(cat)); // Output: Meow

Liskov Substitution Principle ensures that a child class can substitute its parent class without affecting the program’s correctness. With TypeScript, we’ll see how this principle is upheld through a simple example involving Animal, Dog, and Cat classes.

Interface Segregation Principle with TypeScript

The Interface Segregation Principle (ISP) suggests that a class should not be forced to implement interfaces it does not use. In TypeScript, this means creating smaller, more focused interfaces.

In the example, the BulkyRepository class violates ISP by implementing a bulky interface with read, write, and delete methods. To adhere to ISP, we segregate the interfaces into Readable, Writable, and Deletable. We then create segregated interfaces (ReadableRepository, WritableRepository, DeletableRepository) and implement them in the SegregatedRepository class. Now, classes can choose to implement only the interfaces they need.

interface Readable {
  read(): string;
}

interface Writable {
  write(data: string): void;
}

interface Deletable {
  delete(): void;
}

// Bulky interface violating Interface Segregation Principle
class BulkyRepository implements Readable, Writable, Deletable {
  read(): string {
    return "Reading data...";
  }

  write(data: string): void {
    console.log(`Writing data: ${data}`);
  }

  delete(): void {
    console.log("Deleting data...");
  }
}
// Segregated interfaces following Interface Segregation Principle
interface ReadableRepository extends Readable {}
interface WritableRepository extends Writable {}
interface DeletableRepository extends Deletable {}

class SegregatedRepository
  implements ReadableRepository, WritableRepository, DeletableRepository
{
  read(): string {
    return "Reading data...";
  }

  write(data: string): void {
    console.log(`Writing data: ${data}`);
  }

  delete(): void {
    console.log("Deleting data...");
  }
}

The Interface Segregation Principle advocates for creating smaller, more focused interfaces, preventing classes from being forced to implement methods they don’t use. We’ll demonstrate the principle in TypeScript by segregating interfaces and implementing them in a more modular manner.

Dependency Inversion Principle

The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules; both should depend on abstractions. In TypeScript, this involves creating interfaces to represent dependencies and injecting them into classes.

Let’s illustrate the Dependency Inversion Principle with a TypeScript example. Consider a simple scenario where you have a LightSwitch class that controls a light. Initially, the LightSwitch directly depends on the Light class.

class Light {
  turnOn() {
    console.log("Light is ON");
  }

  turnOff() {
    console.log("Light is OFF");
  }
}

class LightSwitch {
  private light: Light;

  constructor(light: Light) {
    this.light = light;
  }

  flip() {
    if (this.light.isOn()) {
      this.light.turnOff();
    } else {
      this.light.turnOn();
    }
  }
}

const light = new Light();
const switchButton = new LightSwitch(light);

switchButton.flip();

In this example, the LightSwitch directly depends on the Light class. Now, let’s apply the Dependency Inversion Principle by introducing an abstraction, such as an interface (Switchable), that both Light and LightSwitch depend on.

interface Switchable {
  turnOn(): void;
  turnOff(): void;
  isOn(): boolean;
}

class Light implements Switchable {
  private isLightOn: boolean = false;

  turnOn() {
    this.isLightOn = true;
    console.log("Light is ON");
  }

  turnOff() {
    this.isLightOn = false;
    console.log("Light is OFF");
  }

  isOn() {
    return this.isLightOn;
  }
}

class LightSwitch {
  private device: Switchable;

  constructor(device: Switchable) {
    this.device = device;
  }

  flip() {
    if (this.device.isOn()) {
      this.device.turnOff();
    } else {
      this.device.turnOn();
    }
  }
}

const light = new Light();
const switchButton = new LightSwitch(light);

switchButton.flip();

Now, both Light and LightSwitch depend on the Switchable interface, adhering to the Dependency Inversion Principle. This makes the design more flexible, as you can easily introduce new classes that implement the Switchable interface without modifying the existing code.

Dependency Inversion Principle emphasizes that high-level modules should not depend on low-level modules; both should depend on abstractions. We’ll explore how TypeScript facilitates dependency inversion through interfaces and constructor injection.

Conclusion

In conclusion, the SOLID principles provide a foundation for writing maintainable and scalable code. While understanding these principles is crucial, it’s equally important to apply them judiciously, considering the specific needs of your project. TypeScript, with its strong static typing and object-oriented features, aligns well with SOLID principles, offering a powerful toolset for building robust applications. As you delve into the examples and apply these principles in your TypeScript projects, remember that simplicity remains a key tenet of good code. Happy coding!