Applying SOLID Principles with TypeScript

Table of Contents

  • Introduction
  • What are the SOLID Principles?
  • S – Single Responsibility Principle (SRP)
  • O – Open/Closed Principle (OCP)
  • L – Liskov Substitution Principle (LSP)
  • I – Interface Segregation Principle (ISP)
  • D – Dependency Inversion Principle (DIP)
  • Best Practices for SOLID in TypeScript
  • Conclusion

Introduction

SOLID is an acronym for five principles of object-oriented programming and design. Originally popularized by Robert C. Martin (Uncle Bob), these principles help developers create systems that are easier to maintain, scale, and refactor.

In TypeScript, which supports object-oriented paradigms alongside functional programming features, applying SOLID principles can significantly enhance the quality of your codebase.


What are the SOLID Principles?

  • S: Single Responsibility Principle (SRP)
  • O: Open/Closed Principle (OCP)
  • L: Liskov Substitution Principle (LSP)
  • I: Interface Segregation Principle (ISP)
  • D: Dependency Inversion Principle (DIP)

Let’s dive into each with examples in TypeScript.


S – Single Responsibility Principle (SRP)

Definition:
A class should have one, and only one, reason to change.

Each module/class should focus on a single part of the functionality provided by the software.

Example (Violating SRP):

class UserService {
createUser() {
// logic to create a user
}

sendEmail() {
// logic to send an email
}
}

The UserService is handling both user creation and sending emails — two different responsibilities.

Correct Application:

class UserService {
createUser() {
// logic to create a user
}
}

class EmailService {
sendEmail() {
// logic to send an email
}
}

Now, each class has a single responsibility.


O – Open/Closed Principle (OCP)

Definition:
Software entities should be open for extension but closed for modification.

You should be able to add new behavior without altering existing code.

Example (Violating OCP):

class PaymentProcessor {
processPayment(type: string) {
if (type === 'credit') {
// process credit card
} else if (type === 'paypal') {
// process PayPal
}
}
}

Adding new payment types requires modifying PaymentProcessor, risking breaking existing logic.

Correct Application with OCP:

interface PaymentMethod {
pay(amount: number): void;
}

class CreditCardPayment implements PaymentMethod {
pay(amount: number) {
console.log(`Paid ${amount} using Credit Card`);
}
}

class PaypalPayment implements PaymentMethod {
pay(amount: number) {
console.log(`Paid ${amount} using PayPal`);
}
}

class PaymentProcessor {
constructor(private paymentMethod: PaymentMethod) {}

processPayment(amount: number) {
this.paymentMethod.pay(amount);
}
}

Adding new payment methods now only requires creating new classes implementing PaymentMethod, without modifying PaymentProcessor.


L – Liskov Substitution Principle (LSP)

Definition:
Subtypes must be substitutable for their base types without altering the correctness of the program.

Example (Violating LSP):

class Bird {
fly() {
console.log('Flying');
}
}

class Ostrich extends Bird {
fly() {
throw new Error('Ostriches cannot fly!');
}
}

An Ostrich is a Bird but cannot fly. Using an Ostrich where a Bird is expected would break the program.

Correct Application:

Separate behaviors:

abstract class Bird {
abstract move(): void;
}

class FlyingBird extends Bird {
move() {
console.log('Flying');
}
}

class WalkingBird extends Bird {
move() {
console.log('Walking');
}
}

class Ostrich extends WalkingBird {}

class Sparrow extends FlyingBird {}

Now, birds behave correctly according to their abilities.


I – Interface Segregation Principle (ISP)

Definition:
Clients should not be forced to depend on interfaces they do not use.

Example (Violating ISP):

interface Worker {
work(): void;
eat(): void;
}

class Robot implements Worker {
work() {
console.log('Robot working');
}

eat() {
throw new Error('Robots do not eat');
}
}

The Robot class is forced to implement eat(), which makes no sense.

Correct Application:

Split interfaces:

interface Workable {
work(): void;
}

interface Eatable {
eat(): void;
}

class Human implements Workable, Eatable {
work() {
console.log('Human working');
}

eat() {
console.log('Human eating');
}
}

class Robot implements Workable {
work() {
console.log('Robot working');
}
}

Each class only implements the methods it actually uses.


D – Dependency Inversion Principle (DIP)

Definition:
High-level modules should not depend on low-level modules. Both should depend on abstractions.

Example (Violating DIP):

class MySQLDatabase {
connect() {
console.log('Connecting to MySQL');
}
}

class UserService {
database = new MySQLDatabase();

createUser() {
this.database.connect();
console.log('User created');
}
}

UserService is tightly coupled to MySQLDatabase.

Correct Application:

Use an abstraction:

interface Database {
connect(): void;
}

class MySQLDatabase implements Database {
connect() {
console.log('Connecting to MySQL');
}
}

class PostgresDatabase implements Database {
connect() {
console.log('Connecting to PostgreSQL');
}
}

class UserService {
constructor(private database: Database) {}

createUser() {
this.database.connect();
console.log('User created');
}
}

// Now you can inject any database implementation
const mysqlDb = new MySQLDatabase();
const userService = new UserService(mysqlDb);
userService.createUser();

Now UserService depends on an abstraction (Database), not a specific implementation.


Best Practices for SOLID in TypeScript

  • Use Interfaces and Abstract Classes: Promote abstraction to decouple high-level and low-level modules.
  • Apply Constructor Injection: Inject dependencies through constructors for better flexibility and testability.
  • Avoid God Classes: Keep classes focused on single tasks.
  • Think Extensibility: Design classes in a way that extending functionality doesn’t mean modifying existing code.
  • Write Unit Tests: SOLID principles naturally lead to code that is easier to test.

Conclusion

Applying the SOLID principles in TypeScript can greatly improve your code’s readability, maintainability, scalability, and testability.

Although following these principles might require a little extra thought and design effort upfront, they pay off immensely in the long run by reducing bugs, simplifying feature additions, and easing team collaboration.

By mastering SOLID in TypeScript, you build software that truly embraces good software engineering practices.