SOLID Principles in TypeScript: Examples for Each Principle
Software engineering is an ever-evolving field that demands a high level of quality and maintainability. The SOLID principles are a set of guidelines that help software engineers design code that is modular, scalable, and easy to maintain. In this article, we will explore the five SOLID principles and provide examples of how to apply them in TypeScript.
The SOLID Principles
The SOLID principles are a set of principles that guide the design of object-oriented software. They were introduced by Robert C. Martin (also known as Uncle Bob) in the early 2000s as a way to help developers create code that is easy to understand, modify, and extend. The SOLID principles are:
- Single Responsibility Principle (SRP): A class should have only one reason to change.
- Open-Closed Principle (OCP): Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
- Liskov Substitution Principle (LSP): Subtypes must be substitutable for their base types.
- Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use.
- Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions.
Let’s look at each of these principles in more detail and see how we can apply them in TypeScript.
Single Responsibility Principle (SRP)
The SRP states that a class should have only one reason to change. This means that a class should have only one responsibility or task. If a class has more than one responsibility, then changes to one responsibility may affect the other responsibilities. This can lead to code that is difficult to understand, maintain, and test.
Let’s take a look at an example of how to apply the SRP in TypeScript:
class Employee {
constructor(public name: string, public salary: number) {}
getName(): string {
return this.name;
}
getSalary(): number {
return this.salary;
}
}
class Payroll {
calculatePayroll(employees: Employee[]): void {
for (let employee of employees) {
console.log(`${employee.getName()} - ${employee.getSalary()}`);
}
}
}
In this example, we have two classes: Employee
and Payroll
. The Employee
class is responsible for storing information about an employee, such as their name and salary. The Payroll
class is responsible for calculating the payroll for a list of employees. By separating these responsibilities into separate classes, we can ensure that changes to one responsibility will not affect the other.
Open-Closed Principle (OCP)
The OCP states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. This means that we should be able to extend the behavior of a class without modifying its source code. This can help us create code that is more modular, scalable, and easy to maintain.
Let’s take a look at an example of how to apply the OCP in TypeScript:
interface Shape {
area(): number;
}
class Circle implements Shape {
constructor(public radius: number) {}
area() {
return Math.PI * this.radius ** 2;
}
}
class Square implements Shape {
constructor(public width: number) {}
area() {
return this.width ** 2;
}
}
class AreaCalculator {
constructor(private shapes: Shape[]) {}
calculate() {
let area = 0;
for (let shape of this.shapes) {
area += shape.area();
}
return area;
}
}
In this example, we have three classes: Shape
, Circle
, and Square
. The Shape
interface defines a method area()
that must be implemented by any class that implements the Shape
interface. The Circle
and Square
classes implement the Shape
interface and define their own implementations of the area()
method.
The AreaCalculator
class takes an array of shapes in its constructor and has a calculate()
method that loops through each shape and calculates its area. By using an interface to define the Shape
class and implementing it in the Circle
and Square
classes, we can extend the behavior of the AreaCalculator
class without modifying its source code.
Liskov Substitution Principle (LSP)
The LSP states that subtypes must be substitutable for their base types. This means that any subclass or derived class should be able to be used in place of its parent class without any unexpected behavior. This can help us create code that is more modular, scalable, and flexible.
Let’s take a look at an example of how to apply the LSP in TypeScript:
class Vehicle {
startEngine(): void {
console.log('Starting engine...');
}
}
class Car extends Vehicle {
startEngine(): void {
console.log('Starting car engine...');
}
}
class Motorcycle extends Vehicle {}
function startVehicle(vehicle: Vehicle): void {
vehicle.startEngine();
}
const car = new Car();
const motorcycle = new Motorcycle();
startVehicle(car); // Output: Starting car engine...
startVehicle(motorcycle); // Output: Starting engine...
In this example, we have three classes: Vehicle
, Car
, and Motorcycle
. The Vehicle
class defines a method startEngine()
that must be implemented by any class that extends it. The Car
class overrides the startEngine()
method to provide a more specific implementation for starting the engine of a car. The Motorcycle
class inherits the startEngine()
method from the Vehicle
class.
The startVehicle()
function takes a Vehicle
object as a parameter and calls its startEngine()
method. We can pass both a Car
object and a Motorcycle
object to this function because both classes are subtypes of the Vehicle
class and can be used interchangeably.
Interface Segregation Principle (ISP)
The ISP states that clients should not be forced to depend on interfaces they do not use. This means that we should break up large interfaces into smaller, more specific interfaces that only contain the methods that are relevant to each client. This can help us create code that is more modular, scalable, and easy to maintain.
Let’s take a look at an example of how to apply the ISP in TypeScript:
interface Order {
placeOrder(): void;
cancelOrder(): void;
}
interface Payable {
pay(): void;
}
interface Shippable {
shipOrder(): void;
}
class OnlineOrder implements Order, Payable, Shippable {
placeOrder(): void {
console.log('Placing online order...');
}
cancelOrder(): void {
console.log('Canceling online order...');
}
pay(): void {
console.log('Paying for online order...');
}
shipOrder(): void {
console.log('Shipping online order...');
}
}
class InPersonOrder implements Order {
placeOrder(): void {
console.log('Placing in-person order...');
}
cancelOrder(): void {
console.log('Canceling in-person order...');
}
}
function processOrder(order: Order & Payable & Shippable): void {
order.placeOrder();
order.pay();
order.shipOrder();
}
const onlineOrder = new OnlineOrder();
const inPersonOrder = new InPersonOrder();
processOrder(onlineOrder); // Output: Placing online order... Paying for online order... Shipping online order...
In this example, we have four classes/interfaces: Order
, Payable
, Shippable
, OnlineOrder
, and InPersonOrder
. The Order
interface defines the methods placeOrder()
and cancelOrder()
. The Payable
interface defines the method pay()
. The Shippable
interface defines the method shipOrder()
. The OnlineOrder
class implements all three interfaces and provides an implementation for each method. The InPersonOrder
class only implements the Order
interface and provides its own implementation for the placeOrder()
and cancelOrder()
methods.
The processOrder()
function takes an object that must implement the Order
, Payable
, and Shippable
interfaces. This ensures that the object has all the necessary methods to process an order that can be paid for and shipped. We can pass both an OnlineOrder
object and an InPersonOrder
object to this function because the InPersonOrder
class still implements the Order
interface.
Dependency Inversion Principle (DIP)
The DIP states that high-level modules should not depend on low-level modules. Both should depend on abstractions. This means that we should design our code in a way that allows us to easily replace low-level implementation details with abstractions. This can help us create code that is more modular, scalable, and easy to maintain.
Let’s take a look at an example of how to apply the DIP in TypeScript:
interface Database {
read(): string;
}
class MySQL implements Database {
read(): string {
return 'Reading from MySQL database...';
}
}
class PostgreSQL implements Database {
read(): string {
return 'Reading from PostgreSQL database...';
}
}
class App {
constructor(private database: Database) {}
readData(): string {
return this.database.read();
}
}
const mySQLDatabase = new MySQL();
const postgreSQLDatabase = new PostgreSQL();
const myApp = new App(mySQLDatabase);
console.log(myApp.readData()); // Output: Reading from MySQL database...
const myOtherApp = new App(postgreSQLDatabase);
console.log(myOtherApp.readData()); // Output: Reading from PostgreSQL database...
In this example, we have three classes/interfaces: Database
, MySQL
, and PostgreSQL
, and the App
class. The Database
interface defines the method read()
. The MySQL
and PostgreSQL
classes implement the Database
interface and provide their own implementation of the read()
method.
The App
class takes a Database
object in its constructor and has a readData()
method that calls the read()
method of the Database
object. By using an interface to define the Database
class and injecting it into the App
class, we can easily replace the low-level implementation details (e.g., MySQL or PostgreSQL) with an abstraction in the future without having to modify the App
class.
Conclusion
The SOLID principles are essential for creating software that is easy to understand, modify, and extend. By following these principles, we can create code that is more modular, scalable, and easy to maintain. In this article, we have explored the five SOLID principles and provided examples of how to apply them in TypeScript.
We looked at the Single Responsibility Principle (SRP), Open-Closed Principle (OCP), Liskov Substitution Principle (LSP), Interface Segregation Principle (ISP), and Dependency Inversion Principle (DIP) and provided examples of how to apply each principle in TypeScript.
By understanding and applying these principles, you can write cleaner, more maintainable, and more scalable code that will save you time and headaches in the long run.