DEV Community

Cover image for Open/Closed Principle In Typescript
Hasan Zohdy
Hasan Zohdy

Posted on

Open/Closed Principle In Typescript

Introduction

Open/Closed Principle is the second principle in SOLID principles, it states that, a software entity should be open for extension, but closed for modification.

What is a software entity?

A software entity is a class, module, function, etc. Basically, it is a piece of code that does something.

What does that mean?

Basically, you should be able to extend the behavior of a software entity without modifying its source code.

Why would i need that?

Actually, for many reasons, let's list them:

  1. Maintainable,Your code will be more maintainable, because you will not need to modify the source code of the software entity.
  2. Testable, as it does not require modification, you will not need to modify the tests.
  3. Reusable, you can use the software entity in other places which can be easily extended.
  4. Scalable, add more features or functions without the need to modify the original source code.

Enough Talking... Show me the code

Let's say we have a User class, and we want to add a new feature to it, which is sendEmail function, which sends an email to the user.

class User {
  constructor(public name: string, public email: string) {}

  sendEmail() {
    console.log(`Sending email to ${this.name} at ${this.email}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, we want to add a new feature to the User class, which is sendSMS function, which sends an SMS to the user.

class User {
  constructor(public name: string, public email: string) {}

  sendEmail() {
    console.log(`Sending email to ${this.name} at ${this.email}`);
  }

  sendSMS() {
    console.log(`Sending SMS to ${this.name} at ${this.email}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, we want to add a new feature to the User class, which is sendPushNotification function, which sends a push notification to the user.

class User {
  constructor(public name: string, public email: string) {}

  sendEmail() {
    console.log(`Sending email to ${this.name} at ${this.email}`);
  }

  sendSMS() {
    console.log(`Sending SMS to ${this.name} at ${this.email}`);
  }

  sendPushNotification() {
    console.log(`Sending Push Notification to ${this.name} at ${this.email}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, we want to add a new feature to the User class, which is sendWhatsAppMessage function, which sends a WhatsApp message to the user.

class User {
  constructor(public name: string, public email: string) {}

  sendEmail() {
    console.log(`Sending email to ${this.name} at ${this.email}`);
  }

  sendSMS() {
    console.log(`Sending SMS to ${this.name} at ${this.email}`);
  }

  sendPushNotification() {
    console.log(`Sending Push Notification to ${this.name} at ${this.email}`);
  }

  sendWhatsAppMessage() {
    console.log(`Sending WhatsApp Message to ${this.name} at ${this.email}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

This is good, but we still have to modify the User class every time we want to add a new feature, which is not totally good, because we are modifying the User class and make it over complicated.

Now let's head to the Open/Closed Principle solution.

Open/Closed Principle Solution

The idea is simple, we will make a contract A.K.A interface, which will be used among variant classes, and each class will implement the contract, and add its own implementation.

The point here is to make the contract as generic as possible, to allow multiple communication channels without touching the original User class.

Step 1: Create a contract

interface CommunicationChannel {
  send(user: User): void;
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Create a class for each communication channel

class EmailChannel implements CommunicationChannel {
  send(user: User) {
    console.log(`Sending email to ${user.name} at ${user.email}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we can add as many communication channels as we want, without touching the User class.

class SMSChannel implements CommunicationChannel {
  send(user: User) {
    console.log(`Sending SMS to ${user.name} at ${user.email}`);
  }
}
Enter fullscreen mode Exit fullscreen mode
class PushNotificationChannel implements CommunicationChannel {
  send(user: User) {
    console.log(`Sending Push Notification to ${user.name} at ${user.email}`);
  }
}
Enter fullscreen mode Exit fullscreen mode
class WhatsAppChannel implements CommunicationChannel {
  send(user: User) {
    console.log(`Sending WhatsApp Message to ${user.name} at ${user.email}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Area Calculator Example

Let's take another example, we have a Shape and an AreaCalculator classes, and we want to add a new shape to the AreaCalculator class.

class Shape {
  constructor(public type: string) {}
}

class AreaCalculator {
  constructor(public shapes: Shape[]) {}

  area() {
    return this.shapes.reduce((sum, shape) => {
      if (shape.type === "circle") {
        sum += Math.PI * Math.pow(shape.radius, 2);
      } else if (shape.type === "square") {
        sum += Math.pow(shape.side, 2);
      } else if (shape.type === "rectangle") {
        sum += shape.width * shape.height;
      }

      return sum;
    }, 0);
  }
}
Enter fullscreen mode Exit fullscreen mode

What if we want to add a new shape, let's say triangle, we will have to modify the AreaCalculator class, which is not good.

class Shape {
  constructor(public type: string) {}
}

class AreaCalculator {
  constructor(public shapes: Shape[]) {}

  area() {
    return this.shapes.reduce((sum, shape) => {
      if (shape.type === "circle") {
        sum += Math.PI * Math.pow(shape.radius, 2);
      } else if (shape.type === "square") {
        sum += Math.pow(shape.side, 2);
      } else if (shape.type === "rectangle") {
        sum += shape.width * shape.height;
      } else if (shape.type === "triangle") {
        sum += (shape.base * shape.height) / 2;
      }

      return sum;
    }, 0);
  }
}
Enter fullscreen mode Exit fullscreen mode

TOO BIG, is'nt it? let's break it down.

Step 1: Create a contract

interface Shape {
    area(): number;
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Create a class for each shape

class SquareShape implements Shape {
    public constructor(public side: number) {}

    public area() {
        return this.side * this.side;
    }
}
Enter fullscreen mode Exit fullscreen mode
class CircleShape implements Shape {
    public constructor(public radius: number) {}

    public area() {
        return Math.PI * Math.pow(this.radius, 2);
    }
}
Enter fullscreen mode Exit fullscreen mode
class RectangleShape implements Shape {
    public constructor(public width: number, public height: number) {}

    public area() {
        return this.width * this.height;
    }
}
Enter fullscreen mode Exit fullscreen mode
class TriangleShape implements Shape {
    public constructor(public base: number, public height: number) {}

    public area() {
        return (this.base * this.height) / 2;
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Use the contract

class AreaCalculator {
    public constructor(public shapes: Shape[]) {}

    public sum() {
        return this.shapes.reduce((total, shape) => {
            return total + shape.area();
        }, 0);
    }
}
Enter fullscreen mode Exit fullscreen mode

Clean, clear, easy to read and maintainable!

Conclusion

Open/Closed principle is very essential and crucial when you build your software architecture, it will make your code more maintainable, testable, reusable and scalable.

Following the list

You can see the updated list of design principles from the following link
https://mentoor.io/en/posts/634524154/open-closed-principle-in-typescript

Join us in our Discord Community
https://discord.gg/XDZcTuU8c8

Top comments (0)