- Published on
Mastering SOLID Principles in TypeScript - Real Examples & Best Practices
- πΉ 1. Single Responsibility Principle (SRP)
- πΉ 2. Open/Closed Principle (OCP)
- πΉ 3. Liskov Substitution Principle (LSP)
- πΉ 4. Interface Segregation Principle (ISP)
- πΉ 5. Dependency Inversion Principle (DIP)
- π§ Pro Tips for Applying SOLID in Real Projects
- π Benefits of SOLID
When building robust, scalable, and maintainable software, design principles are your best ally. SOLID is an acronym for five core object-oriented design principles that lead to better software architecture. Understanding and applying these principles can significantly improve your code quality, testability, and flexibility.
Let's dive into each principle, understand what it means, and walk through bad vs. improved TypeScript code examples.
πΉ 1. Single Responsibility Principle (SRP)
Definition: A class should have only one reason to change.
π Explanation:
A class should do one job and do it well. Mixing multiple responsibilities makes code harder to maintain, test, and scale.
β Bad Code
class ReportGenerator {
generateReport(data: any) { /* ... */ }
saveToDatabase(report: string) { /* ... */ }
printReport(report: string) { /* ... */ }
}
This class is handling three responsibilities, violating SRP.
β Improved Code
class ReportGenerator {
generateReport(data: any): string {
return "Generated Report";
}
}
class ReportSaver {
saveToDatabase(report: string) { /* ... */ }
}
class ReportPrinter {
printReport(report: string) { /* ... */ }
}
// Usage
const generator = new ReportGenerator();
const saver = new ReportSaver();
const printer = new ReportPrinter();
const report = generator.generateReport({});
saver.saveToDatabase(report);
printer.printReport(report);
Now, each class has one responsibility, making the system modular and easy to test or extend.
πΉ 2. Open/Closed Principle (OCP)
Definition: Software entities should be open for extension but closed for modification.
π Explanation:
Add new functionality without changing existing code. This prevents bugs and keeps your codebase stable.
β Bad Code
class NotificationService {
sendNotification(type: string, message: string) {
if (type === "email") { /* ... */ }
else if (type === "sms") { /* ... */ }
}
}
Every time you add a new notification type, you must modify this method.
β Improved Code
interface Notification {
send(message: string): void;
}
class EmailNotification implements Notification {
send(message: string) { /* ... */ }
}
class SMSNotification implements Notification {
send(message: string) { /* ... */ }
}
class NotificationService {
constructor(private notification: Notification) {}
notify(message: string) {
this.notification.send(message);
}
}
// Usage
new NotificationService(new EmailNotification()).notify("Email Msg");
Now, to support a new notification type, you only add a new classβno existing code needs changing.
πΉ 3. Liskov Substitution Principle (LSP)
Definition: Subtypes must be substitutable for their base types without altering program behavior.
π Explanation:
Subclasses should behave like their parent class. Violating this leads to unexpected behavior in polymorphic code.
β Bad Code
class Bird {
fly() {
console.log("Flying");
}
}
class Penguin extends Bird {
fly() {
throw new Error("Penguins can't fly");
}
}
β Improved Code
abstract class Bird {
abstract move(): void;
}
class FlyingBird extends Bird {
move() {
console.log("Flying");
}
}
class NonFlyingBird extends Bird {
move() {
console.log("Walking");
}
}
Now, all subclasses respect the contract and behave as expected, ensuring safe substitution.
πΉ 4. Interface Segregation Principle (ISP)
Definition: Clients should not be forced to implement interfaces they do not use.
π Explanation:
Break large interfaces into smaller, specific ones. This avoids forcing classes to implement irrelevant methods.
β Bad Code
interface Animal {
eat(): void;
fly(): void;
swim(): void;
}
class Dog implements Animal {
eat() { /* ... */ }
fly() { throw new Error("Dogs can't fly"); }
swim() { /* ... */ }
}
β Improved Code
interface Eatable { eat(): void; }
interface Flyable { fly(): void; }
interface Swimmable { swim(): void; }
class Dog implements Eatable, Swimmable {
eat() { /* ... */ }
swim() { /* ... */ }
}
Now, classes implement only what they need. Interfaces are tailored and lean.
πΉ 5. Dependency Inversion Principle (DIP)
Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions.
π Explanation:
Relying on interfaces instead of concrete classes improves flexibility and testability.
β Bad Code
class SQLDatabase {
connect() { /* ... */ }
}
class App {
private db = new SQLDatabase();
start() {
this.db.connect();
}
}
Tightly coupled. You can't swap databases easily.
β Improved Code
interface Database {
connect(): void;
}
class SQLDatabase implements Database {
connect() { /* ... */ }
}
class MongoDB implements Database {
connect() { /* ... */ }
}
class App {
constructor(private db: Database) {}
start() {
this.db.connect();
}
}
// Usage
new App(new MongoDB()).start();
Now, your app is flexible and testable, thanks to abstractions.
π§ Pro Tips for Applying SOLID in Real Projects
- β Use dependency injection frameworks like InversifyJS in TypeScript.
- β Favor composition over inheritance.
- β Start smallβrefactor legacy code incrementally.
- β Use unit tests to detect and prevent SRP and LSP violations.
- β Review large classes (>300 lines) or those with too many constructor paramsβthey often violate SRP or DIP.
π Benefits of SOLID
- Better scalability and maintainability
- Easier unit testing and mocking
- Enhanced team collaboration due to clear responsibilities
- Reduced bugs from changes in existing code
- Future-proof and extensible architecture
By mastering these principles, your TypeScript code becomes cleaner, more modular, and easier to evolve over time.