Introduction: Why SOLID is Important in TypeScript
When developing in TypeScript, especially in large projects, maintaining code cleanliness and flexibility becomes critically important. SOLID is a set of five fundamental principles of object-oriented programming that help create scalable, easily testable, and maintainable applications. These principles are particularly relevant for TypeScript, which adds static typing and interfaces to JavaScript, providing powerful tools for their implementation.
In this article, we will break down each of the five SOLID principles from a TypeScript perspective, accompanying the theory with practical code examples. You will learn how to apply these principles to improve the architecture of your projects.
1. Single Responsibility Principle
Theory: Each class or module should have only one reason to change. In other words, a class should be responsible for only one part of the program's functionality.
Violation of the principle:
// ❌ Bad: class is responsible for data, saving, and formattingclass User { constructor(public name: string, public email: string) {}
saveToDatabase(): void { console.log(`Saving ${this.name} to DB`); }
generateHTML(): string { return `<div>${this.name} - ${this.email}</div>`; }}Fix applying SRP:
// ✅ Good: separate responsibilities into three classesclass User { constructor(public name: string, public email: string) {}}
class UserRepository { save(user: User): void { console.log(`Saving ${user.name} to DB`); }}
class UserView { render(user: User): string { return `<div>${user.name} - ${user.email}</div>`; }}Now each class has one clear responsibility, which simplifies testing and code modification.
2. Open-Closed Principle
Theory: Software entities (classes, modules, functions) should be open for extension but closed for modification. This means you should be able to add new functionality without changing existing code.
Violation of the principle:
// ❌ Bad: when adding a new shape type, you need to modify the AreaCalculator classclass AreaCalculator { calculate(shape: any): number { if (shape.type === 'circle') { return Math.PI * shape.radius ** 2; } else if (shape.type === 'rectangle') { return shape.width * shape.height; } return 0; }}Fix applying OCP:
// ✅ Good: use interface and polymorphisminterface Shape { area(): number;}
class Circle implements Shape { constructor(public radius: number) {} area(): number { return Math.PI * this.radius ** 2; }}
class Rectangle implements Shape { constructor(public width: number, public height: number) {} area(): number { return this.width * this.height; }}
class AreaCalculator { calculate(shape: Shape): number { return shape.area(); // Closed for modification, open for extension }}Now to add a new shape (e.g., Triangle), you just need to create a new class implementing the Shape interface — without modifying AreaCalculator.
3. Liskov Substitution Principle
Theory: Objects in a program should be replaceable with instances of their subtypes without altering the correctness of the program. If you have a base class and a subclass, code that works with the base class should also work correctly with the subclass.
Violation of the principle:
// ❌ Bad