46.1 SOLID Principles Applied in a TypeScript Codebase
Right, let’s talk SOLID. You’ve probably seen the acronym on a thousand blog posts, often accompanied by abstract, frankly useless examples involving Animal classes that makeSound(). We’re not doing that. We’re going to see what these principles actually mean when your keyboard is smeared with coffee and you’re staring at a real, messy TypeScript codebase. Think of them less as rigid laws and more as a set of incredibly useful guidelines for writing code that doesn’t make you want to flip your desk a year from now.
The Single Responsibility Principle (The “Stop Doing Everything” Principle)
This one is the bedrock, and everyone gets it wrong. It doesn’t mean “a class should do one thing.” That’s reductive. It means a class or module should have one, and only one, reason to change.
What’s a “reason to change”? It’s a person or a department. Think about it: if your User class has to change because the marketing team wants a new way to calculate loyalty points, and also has to change because the database team is migrating from PostgreSQL to MongoDB, it has two reasons to change. It’s violating SRP.
Here’s the classic violation. Be honest, you’ve written this:
// 🚨 SRP Violation: The God Class
class User {
public name: string;
public email: string;
constructor(name: string, email: string) {
this.name = name;
this.email = email;
}
// Responsibility 1: User data management
saveToDatabase(): void {
// ... logic to save user to db
}
// Responsibility 2: Data validation
validateEmail(): boolean {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(this.email);
}
// Responsibility 3: Notification logic (!!)
sendWelcomeEmail(): void {
const mailer = new EmailService();
mailer.sendTemplate('welcome', this);
}
}
This class knows about database schemas, email regex, and the specifics of your email templating service. If any of those things change, you’re cracking open this file. It’s a testing nightmare.
Now, let’s apply some sanity:
// ✅ SRP Compliant: Separated concerns
class User {
public name: string;
public email: string;
constructor(name: string, email: string) {
this.name = name;
this.email = email;
}
}
// Responsibility 1: Persistence logic
class UserRepository {
save(user: User): void {
// ... logic to save user to db
}
}
// Responsibility 2: Validation logic
class EmailValidator {
static validate(email: string): boolean {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
}
// Responsibility 3: Notification service
class EmailService {
sendWelcomeEmail(user: User): void {
// ... specifics of sending email
}
}
// Now, assembling the pieces is someone else's job (e.g., a service layer)
const user = new User('Alice', 'alice@example.com');
if (EmailValidator.validate(user.email)) {
new UserRepository().save(user);
new EmailService().sendWelcomeEmail(user);
}
See the difference? Now, if the email API changes, you modify EmailService. If validation rules change, you touch EmailValidator. The User class remains pristine and stable. This is the goal.
The Open/Closed Principle (The “Extend, Don’t Modify” Principle)
This principle sounds like corporate jargon but is profoundly practical: software entities should be open for extension, but closed for modification. In English: you should be able to add new functionality without rewriting existing, working code.
The violation usually involves a gigantic, ever-growing if/switch statement.
// 🚨 OCP Violation: The never-ending if statement
class DiscountCalculator {
getDiscount(type: string, price: number): number {
if (type === 'standard') {
return price * 0.9;
} else if (type === 'premium') {
return price * 0.8;
} else if (type === 'vip') {
return price * 0.7;
}
// ... and now the boss asks for a 'black-friday' discount...
throw new Error('Unknown discount type');
}
}
Every time marketing has a new idea, you have to modify this already tested and deployed class. That’s risky.
Instead, we use abstraction. Define a contract and let new functionality implement it.
// ✅ OCP Compliant: Open for extension via new classes
interface DiscountStrategy {
apply(price: number): number;
}
class StandardDiscount implements DiscountStrategy {
apply(price: number): number {
return price * 0.9;
}
}
class PremiumDiscount implements DiscountStrategy {
apply(price: number): number {
return price * 0.8;
}
}
// The calculator is now closed for modification.
class DiscountCalculator {
calculate(price: number, strategy: DiscountStrategy): number {
return strategy.apply(price);
}
}
// Usage: Easily extend with new strategies without touching the calculator.
const calculator = new DiscountCalculator();
const discountPrice = calculator.calculate(100, new PremiumDiscount());
// Adding a new 'BlackFridayDiscount'? Just create a new class.
class BlackFridayDiscount implements DiscountStrategy {
apply(price: number): number {
return price * 0.5; // because why not?
}
}
// The DiscountCalculator doesn't need to know it exists. Magic.
This is where TypeScript’s type system shines. The DiscountCalculator depends on the DiscountStrategy interface, not on concrete implementations. It’s blissfully unaware of the chaos of new marketing campaigns, and your codebase becomes infinitely more flexible.
The Liskov Substitution Principle (The “Don’t Break Promises” Principle)
This is the most academic-sounding one, but its violation causes the most insidious bugs. It simply means objects of a superclass should be replaceable with objects of a subclass without breaking the application.
If you have a class Bird with a method fly(), and you create a subclass Penguin, that penguin damn well better be able to fly() in the way the program expects, or you’ve violated LSP. If it can’t, then Penguin is not a valid substitute for Bird and inheritance is the wrong tool.
// 🚨 LSP Violation: The classic square-rectangle problem
class Rectangle {
protected width: number = 0;
protected height: number = 0;
setWidth(width: number) {
this.width = width;
}
setHeight(height: number) {
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
// "A Square is a Rectangle!" ...is it, though?
class Square extends Rectangle {
setWidth(width: number) {
this.width = width;
this.height = width; // 😬 Violates the parent's contract
}
setHeight(height: number) {
this.height = height;
this.width = height; // 😬
}
}
// This function works perfectly with a Rectangle...
function adjustSizeAndGetArea(rect: Rectangle): number {
rect.setWidth(5);
rect.setHeight(4);
return rect.getArea(); // Expects 20
}
const rect = new Rectangle();
const sq = new Square();
console.log(adjustSizeAndGetArea(rect)); // 20, correct.
console.log(adjustSizeAndGetArea(sq)); // 16, WRONG. The function is broken.
The subclass changed the fundamental behavior of the superclass’s methods. The function adjustSizeAndGetArea cannot blindly substitute a Square for a Rectangle. This is a breach of contract.
The fix? Often, it’s about favoring composition over inheritance, or rethinking your hierarchy. Maybe Square and Rectangle should both implement a Shape interface with a getArea() method, but their internal mechanics are separate and not inherited from one another.
The Interface Segregation Principle (The “No Dumping Ground” Principle)
This is SRP’s cousin for interfaces. Clients should not be forced to depend on interfaces they do not use. Don’t create monolithic, “kitchen-sink” interfaces.
Imagine you’re working with a legacy codebase and see this:
// 🚨 ISP Violation: The mega-interface
interface Machine {
print(document: Document): void;
scan(document: Document): void;
fax(document: Document): void;
}
// This forces a simple printer to implement useless methods.
class BasicPrinter implements Machine {
print(document: Document): void {
// Actual work
}
scan(document: Document): void {
throw new Error("This printer can't scan!"); // 🤮
}
fax(document: Document): void {
throw new Error("This printer can't fax!"); // 🤮
}
}
Throwing errors for implemented methods is a clear sign you’ve screwed up. The client code, expecting a Machine, thinks it can scan() and will crash at runtime. Terrible.
The solution is to break the interface into smaller, more specific ones.
// ✅ ISP Compliant: Segregated, focused interfaces
interface Printer {
print(document: Document): void;
}
interface Scanner {
scan(document: Document): void;
}
interface FaxMachine {
fax(document: Document): void;
}
// A simple printer only implements what it needs.
class BasicPrinter implements Printer {
print(document: Document): void {
// Actual work
}
}
// A fancy all-in-one can choose to implement all.
class AllInOnePrinter implements Printer, Scanner, FaxMachine {
print(document: Document): void { /* ... */ }
scan(document: Document): void { /* ... */ }
fax(document: Document): void { /* ... */ }
}
Now, a function that only needs to print can depend on the Printer interface alone. It’s impossible to pass a BasicPrinter to a function that tries to call scan() on it because the type system won’t allow it. You’ve moved the error from runtime to compile-time, which is exactly where we want it.
The Dependency Inversion Principle (The “Don’t Grip Tightly” Principle)
This is the big one that makes testing and refactoring possible. It states:
- High-level modules should not depend on low-level modules. Both should depend on abstractions.
- Abstractions should not depend on details. Details should depend on abstractions.
Stop imagining “modules” as files. Think “more important” (high-level) vs. “less important” (low-level). Your business logic (high-level) should not depend on the specific database you’re using (low-level). Both should depend on an abstract interface.
Here’s the tight coupling we’re trying to avoid:
// 🚨 DIP Violation: High-level module depends on low-level detail
// Low-level module
class PostgreSQLDatabase {
saveUser(user: User) {
// ... very specific PostgreSQL commands
}
}
// High-level module
class UserService {
private database: PostgreSQLDatabase; // 😬 Direct dependency on a concrete class
constructor() {
this.database = new PostgreSQLDatabase(); // 😬 Even worse, instantiates it itself!
}
createUser(user: User) {
this.database.saveUser(user);
}
}
UserService is now permanently welded to PostgreSQL. Want to test UserService? You need a running PostgreSQL database. Want to switch to MongoDB? Time to rewrite UserService.
Let’s invert that dependency:
// ✅ DIP Compliant: Both depend on an abstraction.
// Abstraction (Interface)
interface Database {
saveUser(user: User): void;
}
// Low-level module (Detail) depends on the abstraction
class PostgreSQLDatabase implements Database {
saveUser(user: User) {
// ... specific PostgreSQL commands
}
}
class MongoDBDatabase implements Database {
saveUser(user: User) {
// ... specific MongoDB commands
}
}
// High-level module depends on the abstraction
class UserService {
private database: Database; // Depends on the interface, not the concrete class
constructor(database: Database) { // Dependency is injected! This is key.
this.database = database;
}
createUser(user: User) {
this.database.saveUser(user);
}
}
// Now, wiring everything up is the job of the composition root (e.g., your main app file)
const postgresDB = new PostgreSQLDatabase();
const userService = new UserService(postgresDB); // Inject the dependency
// Testing is a breeze
class MockDatabase implements Database {
saveUser(user: User) {
console.log('Just testing, no database needed!');
}
}
const testService = new UserService(new MockDatabase());
The high-level UserService no longer cares about the details. It just knows it can call saveUser on something that implements the Database interface. This is the cornerstone of testable, maintainable architecture. You’re not just writing code; you’re building a system that can survive the next inevitable change.