Implementing SOLID Principles in TypeScript
SOLID principles are a set of design principles that help developers create clean, maintainable, and scalable software. These principles are especially important in object-oriented programming and can be effectively applied in TypeScript.
In this article, we will explore each of the SOLID principles and demonstrate how to implement them in TypeScript with practical examples.
1. Single-Responsibility Principle (SRP)
A class should have only one reason to change, meaning it should have only one responsibility.
Bad Implementation
class User {
constructor(private name: string, private email: string) {}
saveToDatabase(): void {
console.log(`Saving user ${this.name} to database...`);
}
sendEmail(subject: string, body: string): void {
console.log(`Sending email to ${this.email}: ${subject}`);
}
}
Problem: User
class handles both user data management and email sending, violating SRP.
Good Implementation
Split responsibilities into separate classes (UserRepository
for database operations and EmailService
for email sending).
class User {
constructor(private name: string, private email: string) {}
}
class UserRepository {
saveToDatabase(user: User): void {
console.log(`Saving user ${user.name} to database...`);
}
}
class EmailService {
sendEmail(user: User, subject: string, body: string): void {
console.log(`Sending email to ${user.email}: ${subject}`);
}
}
2. Open-Closed Principle (OCP)
Software entities (classes, modules, functions) should be open for extension but closed for modification.
Bad Implementation
class Discount {
giveDiscount(customerType: string): number {
if (customerType === "regular") {
return 10;
} else if (customerType === "premium") {
return 20;
}
return 0;
}
}
Problem: Adding a new customer type requires modifying the Discount
class.
Good Implementation
Use interfaces and inheritance to extend functionality without modifying existing code.
interface Customer {
getDiscount(): number;
}
class RegularCustomer implements Customer {
getDiscount(): number {
return 10;
}
}
class PremiumCustomer implements Customer {
getDiscount(): number {
return 20;
}
}
class Discount {
giveDiscount(customer: Customer): number {
return customer.getDiscount();
}
}
3. Liskov Substitution Principle (LSP)
Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
Bad Implementation
class Rectangle {
constructor(public width: number, public height: number) {}
setWidth(width: number): void {
this.width = width;
}
setHeight(height: number): void {
this.height = height;
}
area(): number {
return this.width * this.height;
}
}
class Square extends Rectangle {
setWidth(width: number): void {
this.width = width;
this.height = width; // Violates LSP
}
setHeight(height: number): void {
this.height = height;
this.width = height; // Violates LSP
}
}
Problem: Square
changes the behavior of Rectangle
, violating LSP.
Good Implementation
Use a common interface (Shape
) to ensure substitutability.
interface Shape {
area(): number;
}
class Rectangle implements Shape {
constructor(public width: number, public height: number) {}
area(): number {
return this.width * this.height;
}
}
class Square implements Shape {
constructor(public side: number) {}
area(): number {
return this.side * this.side;
}
}
4. Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they do not use.
Bad Implementation
interface Worker {
work(): void;
eat(): void;
}
class Engineer implements Worker {
work(): void {
console.log("Engineering work...");
}
eat(): void {
console.log("Eating...");
}
}
class Robot implements Worker {
work(): void {
console.log("Building...");
}
eat(): void {
throw new Error("Robots don't eat!");
}
}
Problem: Robot
is forced to implement the eat
method, which it doesn’t need.
Good Implementation
Split the interface into smaller, more specific interfaces.
interface Workable {
work(): void;
}
interface Eatable {
eat(): void;
}
class Engineer implements Workable, Eatable {
work(): void {
console.log("Engineering work...");
}
eat(): void {
console.log("Eating...");
}
}
class Robot implements Workable {
work(): void {
console.log("Building...");
}
}
5. Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Bad Implementation
class MySQLDatabase {
save(data: string): void {
console.log(`Saving ${data} to MySQL database...`);
}
}
class App {
private database = new MySQLDatabase();
saveData(data: string): void {
this.database.save(data);
}
}
Problem: App
is tightly coupled to MySQLDatabase
.
Good Implementation
Use dependency injection and depend on abstractions (interfaces) rather than concrete implementations.
interface Database {
save(data: string): void;
}
class MySQLDatabase implements Database {
save(data: string): void {
console.log(`Saving ${data} to MySQL database...`);
}
}
class MongoDBDatabase implements Database {
save(data: string): void {
console.log(`Saving ${data} to MongoDB database...`);
}
}
class App {
constructor(private database: Database) {}
saveData(data: string): void {
this.database.save(data);
}
}
const mySQLApp = new App(new MySQLDatabase());
mySQLApp.saveData("User Data");
const mongoDBApp = new App(new MongoDBDatabase());
mongoDBApp.saveData("User Data");
Conclusion
By following the SOLID principles, you can create TypeScript applications that are:
- Modular (Single-Responsibility Principle).
- Extensible (Open-Closed Principle).
- Reliable (Liskov Substitution Principle).
- Flexible (Interface Segregation Principle).
- Decoupled (Dependency Inversion Principle).
These principles help you write clean, maintainable, and scalable code, making your applications easier to understand, extend, and debug.
Start applying SOLID principles in your TypeScript projects today!