SOLID principles
The SOLID principles are design principles for object-oriented programming. Let's learn how to apply them in TypeScript.
What are the SOLID principles
SOLID is an acronym for the following five principles:
- Single Responsibility Principle
- Open-Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
Single Responsibility Principle (SRP)
A class should have only a single responsibility.
Bad
❌ Bad: A class with multiple responsibilities
// ❌ A class with multiple responsibilities
class TodoList {
private items: string[] = [];
addItem(text: string): void {
this.items.push(text);
}
removeItem(index: number): void {
this.items.splice(index, 1);
}
toString(): string {
return this.items.join('\n');
}
// It also has the responsibility of file operations
saveToFile(filename: string): void {
// Process to save a file
}
loadFromFile(filename: string): void {
// Process to load a file
}
}
Good
✅ Good: Separate the responsibilities
// ✅ Separate the responsibilities
class TodoList {
private items: string[] = [];
addItem(text: string): void {
this.items.push(text);
}
removeItem(index: number): void {
this.items.splice(index, 1);
}
getItems(): string[] {
return [...this.items];
}
toString(): string {
return this.items.join('\n');
}
}
class FileManager {
save(filename: string, data: string): void {
// Process to save a file
}
load(filename: string): string {
// Process to load a file
return '';
}
}
// Usage
const todoList = new TodoList();
const fileManager = new FileManager();
todoList.addItem('Buy groceries');
fileManager.save('todos.txt', todoList.toString());
Open-Closed Principle (OCP)
A class should be open for extension but closed for modification.
Bad
❌ Bad: Requires modification every time a new payment method is added
// ❌ Requires modification every time a new payment method is added
class PaymentProcessor {
processPayment(amount: number, method: string): void {
if (method === 'creditCard') {
console.log(`Processing credit card payment of ${amount}`);
} else if (method === 'paypal') {
console.log(`Processing PayPal payment of ${amount}`);
} else if (method === 'bitcoin') {
// Requires modification every time a new payment method is added
console.log(`Processing Bitcoin payment of ${amount}`);
}
}
}
Good
✅ Good: Open for extension, closed for modification
// ✅ Open for extension, closed for modification
interface PaymentMethod {
process(amount: number): void;
}
class CreditCardPayment implements PaymentMethod {
process(amount: number): void {
console.log(`Processing credit card payment of ${amount}`);
}
}
class PayPalPayment implements PaymentMethod {
process(amount: number): void {
console.log(`Processing PayPal payment of ${amount}`);
}
}
// Adding a new payment method does not require modifying existing code
class BitcoinPayment implements PaymentMethod {
process(amount: number): void {
console.log(`Processing Bitcoin payment of ${amount}`);
}
}
class PaymentProcessor {
processPayment(amount: number, method: PaymentMethod): void {
method.process(amount);
}
}
// Usage
const processor = new PaymentProcessor();
processor.processPayment(100, new CreditCardPayment());
processor.processPayment(200, new BitcoinPayment());
Liskov Substitution Principle (LSP)
A derived class should be substitutable for its base class.
Bad
❌ Bad: The derived class breaks the base class's contract
// ❌ The derived class breaks the base class's contract
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(width: number): void {
this.width = width;
}
setHeight(height: number): void {
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
class Square extends Rectangle {
constructor(size: number) {
super(size, size);
}
// A square must have equal width and height, so this breaks the contract
setWidth(width: number): void {
this.width = width;
this.height = width; // The height is changed too
}
setHeight(height: number): void {
this.width = height;
this.height = height; // The width is changed too
}
}
// A problem occurs
function resizeRectangle(rect: Rectangle): void {
rect.setWidth(10);
rect.setHeight(5);
console.log(rect.getArea()); // Rectangle: 50, Square: 25 (unexpected result)
}
Good
✅ Good: Define a common interface and implement each as an independent class
// ✅ Define a common interface and implement each as an independent class
interface Shape {
getArea(): number;
}
class Rectangle implements Shape {
constructor(private width: number, private height: number) {}
setWidth(width: number): void {
this.width = width;
}
setHeight(height: number): void {
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
class Square implements Shape {
constructor(private size: number) {}
setSize(size: number): void {
this.size = size;
}
getArea(): number {
return this.size * this.size;
}
}
// A function that handles Shape works correctly for both
function printArea(shape: Shape): void {
console.log(`Area: ${shape.getArea()}`);
}
Interface Segregation Principle (ISP)
Clients should not be forced to depend on methods they do not use.
Bad
❌ Bad: An interface that is too large
// ❌ An interface that is too large
// (Worker has the same name as a standard browser type, so we use WorkerRole)
interface WorkerRole {
work(): void;
eat(): void;
sleep(): void;
}
class Human implements WorkerRole {
work(): void {
console.log('Working...');
}
eat(): void {
console.log('Eating...');
}
sleep(): void {
console.log('Sleeping...');
}
}
class Robot implements WorkerRole {
work(): void {
console.log('Working...');
}
eat(): void {
// Robots do not eat!
throw new Error('Robots do not eat');
}
sleep(): void {
// Robots do not sleep!
throw new Error('Robots do not sleep');
}
}
Good
✅ Good: Segregate the interfaces
// ✅ Segregate the interfaces
interface Workable {
work(): void;
}
interface Eatable {
eat(): void;
}
interface Sleepable {
sleep(): void;
}
class Human implements Workable, Eatable, Sleepable {
work(): void {
console.log('Working...');
}
eat(): void {
console.log('Eating...');
}
sleep(): void {
console.log('Sleeping...');
}
}
class Robot implements Workable {
work(): void {
console.log('Working...');
}
// eat and sleep are unnecessary
}
Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules; both should depend on abstractions.
Bad
❌ Bad: Depends directly on a concrete class
// ❌ Depends directly on a concrete class
class MySQLDatabase {
save(data: string): void {
console.log(`Saving to MySQL: ${data}`);
}
}
class UserRepository {
private database = new MySQLDatabase(); // Depends on a concrete class
saveUser(user: string): void {
this.database.save(user);
}
}
Good
✅ Good: Depend on an abstraction (interface)
// ✅ Depend on an abstraction (interface)
interface Database {
save(data: string): void;
}
class MySQLDatabase implements Database {
save(data: string): void {
console.log(`Saving to MySQL: ${data}`);
}
}
class PostgreSQLDatabase implements Database {
save(data: string): void {
console.log(`Saving to PostgreSQL: ${data}`);
}
}
class UserRepository {
// Depends on the interface (dependency injection)
constructor(private database: Database) {}
saveUser(user: string): void {
this.database.save(user);
}
}
// Usage (you can switch databases easily)
const mysqlRepo = new UserRepository(new MySQLDatabase());
const postgresRepo = new UserRepository(new PostgreSQLDatabase());
Exercises
Refactor the following code to follow the SOLID principles
// Problem
class ReportGenerator {
private data: any[];
constructor(data: any[]) {
this.data = data;
}
generatePdfReport(): void {
console.log('Generating PDF report...');
// PDF generation logic
}
generateExcelReport(): void {
console.log('Generating Excel report...');
// Excel generation logic
}
generateHtmlReport(): void {
console.log('Generating HTML report...');
// HTML generation logic
}
saveToFile(filename: string): void {
console.log(`Saving to ${filename}...`);
}
sendByEmail(email: string): void {
console.log(`Sending to ${email}...`);
}
}
Answer:
✅ Good: Refactoring that follows the SOLID principles
// ✅ Refactoring that follows the SOLID principles
// A class that holds data (single responsibility)
interface ReportData {
title: string;
content: string[];
}
// The report formatter interface (OCP, ISP)
interface ReportFormatter {
format(data: ReportData): string;
}
class PdfFormatter implements ReportFormatter {
format(data: ReportData): string {
return `[PDF] ${data.title}\n${data.content.join('\n')}`;
}
}
class ExcelFormatter implements ReportFormatter {
format(data: ReportData): string {
return `[Excel] ${data.title}\n${data.content.join(',')}`;
}
}
class HtmlFormatter implements ReportFormatter {
format(data: ReportData): string {
return `<html><h1>${data.title}</h1><p>${data.content.join('</p><p>')}</p></html>`;
}
}
// The output destination interface (OCP, ISP)
interface ReportOutput {
output(content: string, destination: string): void;
}
class FileOutput implements ReportOutput {
output(content: string, destination: string): void {
console.log(`Saving to file: ${destination}`);
// File saving logic
}
}
class EmailOutput implements ReportOutput {
output(content: string, destination: string): void {
console.log(`Sending email to: ${destination}`);
// Email sending logic
}
}
// The report generation class (DIP - depends on abstractions)
class ReportGenerator {
constructor(
private formatter: ReportFormatter,
private output: ReportOutput
) {}
generate(data: ReportData, destination: string): void {
const formattedContent = this.formatter.format(data);
this.output.output(formattedContent, destination);
}
}
// Usage example
const data: ReportData = {
title: 'Monthly Report',
content: ['Item 1', 'Item 2', 'Item 3'],
};
const pdfToFile = new ReportGenerator(new PdfFormatter(), new FileOutput());
pdfToFile.generate(data, 'report.pdf');
const htmlToEmail = new ReportGenerator(new HtmlFormatter(), new EmailOutput());
htmlToEmail.generate(data, 'user@example.com');
Principles applied:
- SRP: Separate data, formatting, and output
- OCP: Easy to add new formats and output destinations
- LSP: Each implementation honors the interface's contract
- ISP: Keep interfaces small
- DIP: Depend on abstractions rather than concretions
Summary of the SOLID principles
| Letter | Principle | Overview |
|---|---|---|
| S | Single responsibility | A class has only one responsibility |
| O | Open-closed | Open for extension, closed for modification |
| L | Liskov substitution | Derived classes are substitutable |
| I | Segregation | Provide only the interfaces that are needed |
| D | Dependency inversion | Depend on abstractions, not concretions |
What to read next
- Class design — the class fundamentals that support SOLID
- TypeScript Tips — express the principles with the type system