SOLID principles
The SOLID principles are guidelines used in object-oriented programming to design classes that are resilient to change. This article introduces 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 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