SOLID In TypeScript


Introduction to SOLID#

SOLID is an acronym for five principles that guide the design of maintainable software:

  • S — Single Responsibility Principle
  • O — Open/Closed Principle
  • L — Liskov Substitution Principle
  • I — Interface Segregation Principle
  • D — Dependency Inversion Principle

These principles apply across programming languages. This guide shows practical implementations in TypeScript with concrete before/after examples.

Single Responsibility Principle (SRP)#

Before: Multiple Responsibilities#

class User {
  private name: string;
  private email: string;

  constructor(name: string, email: string) {
    this.name = name;
    this.email = email;
  }

  saveToDatabase(): void {
    // Logic to save the user to the database
  }

  sendEmail(content: string): void {
    // Logic to send an email to the user
  }
}

The User class has two reasons to change: user data structure changes or email sending logic changes.

After: Separated Concerns#

class User {
  private name: string;
  private email: string;

  constructor(name: string, email: string) {
    this.name = name;
    this.email = email;
  }
}

class UserRepository {
  save(user: User): void {
    // Logic to save the user to the database
  }
}

class EmailService {
  sendEmail(email: string, content: string): void {
    // Logic to send an email to the given email address
  }
}

Now each class has one reason to change. Changes to persistence don’t affect email logic and vice versa.

Open/Closed Principle (OCP)#

Before: Modification for Extension#

class AreaCalculator {
  calculateRectangleArea(width: number, height: number): number {
    return width * height;
  }

  calculateCircleArea(radius: number): number {
    return Math.PI * radius * radius;
  }
}

Every new shape requires modifying this class.

After: Closed for Modification, Open for Extension#

interface Shape {
  calculateArea(): number;
}

class Rectangle implements Shape {
  private width: number;
  private height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }

  getWidth(): number {
    return this.width;
  }

  getHeight(): number {
    return this.height;
  }

  calculateArea(): number {
    return this.width * this.height;
  }
}

class Circle implements Shape {
  private radius: number;

  constructor(radius: number) {
    this.radius = radius;
  }

  getRadius(): number {
    return this.radius;
  }

  calculateArea(): number {
    return Math.PI * Math.pow(this.radius, 2);
  }
}

class AreaCalculator {
  calculateArea(shape: Shape): number {
    return shape.calculateArea();
  }
}

Now adding new shapes extends the system without modifying existing code.

Liskov Substitution Principle (LSP)#

Before: Violating Substitution#

class Rectangle {
  protected width: number;
  protected height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }

  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 {
  setWidth(width: number): void {
    this.width = width;
    this.height = width;
  }

  setHeight(height: number): void {
    this.height = height;
    this.width = height;
  }
}

function printArea(rectangle: Rectangle): void {
  rectangle.setWidth(4);
  rectangle.setHeight(5);
  console.log(rectangle.getArea());
}

const rectangle = new Rectangle(5, 10);
printArea(rectangle); // Output: 20

const square = new Square(5, 5);
printArea(square); // Violation! Output: 25 (incorrect, expected 20)

Square violates LSP because it can’t be substituted for Rectangle.

After: Proper Substitution#

abstract class Shape {
  abstract getArea(): number;
}

class Rectangle extends Shape {
  protected width: number;
  protected height: number;

  constructor(width: number, height: number) {
    super();
    this.width = width;
    this.height = height;
  }

  setWidth(width: number): void {
    this.width = width;
  }

  setHeight(height: number): void {
    this.height = height;
  }

  getArea(): number {
    return this.width * this.height;
  }
}

class Square extends Shape {
  protected side: number;

  constructor(side: number) {
    super();
    this.side = side;
  }

  setSide(side: number): void {
    this.side = side;
  }

  getArea(): number {
    return Math.pow(this.side, 2);
  }
}

function printArea(shape: Shape): void {
  console.log(shape.getArea());
}

const rectangle = new Rectangle(5, 10);
printArea(rectangle); // Output: 50

const square = new Square(5);
printArea(square); // Output: 25

Now both Rectangle and Square properly implement Shape and are interchangeable.

Interface Segregation Principle (ISP)#

Before: Fat Interface#

interface Vehicle {
  startEngine(): void;
  stopEngine(): void;
  accelerate(): void;
  brake(): void;
  playMusic(): void;
}

// Forces implementation of unnecessary methods
class Motorcycle implements Vehicle {
  startEngine(): void {
    // Implementation
  }

  stopEngine(): void {
    // Implementation
  }

  accelerate(): void {
    // Implementation
  }

  brake(): void {
    // Implementation
  }

  playMusic(): void {
    // Motorcycle typically doesn't have a music system!
    throw new Error("Not supported");
  }
}

After: Segregated Interfaces#

interface Vehicle {
  startEngine(): void;
  stopEngine(): void;
  accelerate(): void;
  brake(): void;
}

interface MusicPlayer {
  playMusic(): void;
}

class Car implements Vehicle, MusicPlayer {
  startEngine(): void {
    // Implementation
  }

  stopEngine(): void {
    // Implementation
  }

  accelerate(): void {
    // Implementation
  }

  brake(): void {
    // Implementation
  }

  playMusic(): void {
    // Implementation
  }
}

class Motorcycle implements Vehicle {
  startEngine(): void {
    // Implementation
  }

  stopEngine(): void {
    // Implementation
  }

  accelerate(): void {
    // Implementation
  }

  brake(): void {
    // Implementation
  }
}

Now classes implement only the interfaces they actually need.

Dependency Inversion Principle (DIP)#

Before: High-Level Depends on Low-Level#

class Database {
  getUser(id: string): User {
    // Direct database implementation
    return new User("name", "email");
  }
}

class UserService {
  private database: Database;

  constructor() {
    this.database = new Database();
  }

  getUser(id: string): User {
    return this.database.getUser(id);
  }
}

UserService depends directly on a concrete Database implementation.

After: Both Depend on Abstraction#

interface Database {
  getUser(id: string): User;
}

class UserService {
  private database: Database;

  constructor(database: Database) {
    this.database = database;
  }

  getUser(id: string): User {
    return this.database.getUser(id);
  }
}

class MySQLDatabase implements Database {
  getUser(id: string): User {
    // MySQL specific implementation
    return new User("name", "email");
  }
}

class MongoDBDatabase implements Database {
  getUser(id: string): User {
    // MongoDB specific implementation
    return new User("name", "email");
  }
}

Now UserService depends on an abstraction (Database interface). Different implementations can be injected without changing UserService.

Putting It All Together#

A complete example showing all principles working together:

// Domain model (no dependencies)
class User {
  constructor(
    private id: string,
    private email: string,
    private name: string,
  ) {}

  getId(): string {
    return this.id;
  }

  getEmail(): string {
    return this.email;
  }

  getName(): string {
    return this.name;
  }
}

// Abstraction (DIP)
interface UserRepository {
  save(user: User): Promise<void>;
  findById(id: string): Promise<User | null>;
}

interface NotificationService {
  sendWelcome(email: string): Promise<void>;
}

// Concrete implementations
class SQLUserRepository implements UserRepository {
  async save(user: User): Promise<void> {
    // Save to SQL database
  }

  async findById(id: string): Promise<User | null> {
    // Query SQL database
    return null;
  }
}

class EmailNotificationService implements NotificationService {
  async sendWelcome(email: string): Promise<void> {
    // Send email
  }
}

// Service layer (SRP)
class UserService {
  constructor(
    private userRepository: UserRepository,
    private notificationService: NotificationService,
  ) {}

  async registerUser(
    email: string,
    name: string,
  ): Promise<void> {
    const user = new User(generateId(), email, name);
    await this.userRepository.save(user);
    await this.notificationService.sendWelcome(email);
  }
}

// Usage
const repository = new SQLUserRepository();
const notification = new EmailNotificationService();
const userService = new UserService(repository, notification);

await userService.registerUser("user@example.com", "John Doe");

Key Takeaways#

  • SRP: Each class has one reason to change
  • OCP: Extend through abstraction, not modification
  • LSP: Subtypes must be substitutable for their base types
  • ISP: Provide focused interfaces clients need
  • DIP: Depend on abstractions, not concrete implementations

These principles work together to create maintainable, flexible, testable TypeScript applications.

Conclusion#

SOLID principles aren’t rules to follow blindly. They’re guidelines for creating code that’s easier to understand, test, and change.

Apply them with judgment. Sometimes the simplest solution that works is better than perfect adherence to all principles. But as systems grow, SOLID becomes increasingly valuable.

Start with one principle and practice it until it becomes natural. Then add another. Over time, designing according to SOLID becomes intuitive.