4.5 SOLID Principles in TypeScript
SOLID is a set of five object-oriented design principles compiled by Robert C. Martin (Uncle Bob). They are guidelines for making code easy to change, easy to understand, and easy to extend. Following them reduces the risk that a change in one place causes unexpected breakage somewhere else.
TypeScript's type system makes the benefits of SOLID highly visible in the code. Interface segregation, dependency inversion, and Liskov substitution are all enforced by type checking. This chapter shows a violation example and a refactored example side by side for each principle, then wraps up with an order-processing system that applies all five principles together.
S — Single Responsibility Principle
"A class should have only one reason to change."
When a single class is responsible for too much, fixing one thing risks breaking another. Separating responsibilities lets you test and change each class independently.
Violation Example
// Bad: one class with too many responsibilities
class UserManager {
// Responsibility 1: manage user data
private users: { id: number; name: string; email: string }[] = [];
addUser(name: string, email: string): void {
const id = this.users.length + 1;
this.users.push({ id, name, email });
}
// Responsibility 2: save to database (changes when DB logic changes)
saveToDatabase(): void {
console.log("Saving to DB:", this.users);
// SQL queries, ORM code, etc.
}
// Responsibility 3: send email (changes when email template changes)
sendWelcomeEmail(userId: number): void {
const user = this.users.find((u) => u.id === userId);
console.log(`Sending welcome email to ${user?.email}`);
}
// Responsibility 4: generate report (changes when report format changes)
generateReport(): string {
return this.users.map((u) => `${u.id}: ${u.name}`).join("\n");
}
}
// This class can be forced to change for DB reasons, email reasons, and report reasons.
Refactored — Separated Responsibilities
// Good: each class has exactly one responsibility
interface User {
id: number;
name: string;
email: string;
}
// Responsibility 1: manage user data only
class UserRepository {
private users: User[] = [];
add(name: string, email: string): User {
const user: User = { id: this.users.length + 1, name, email };
this.users.push(user);
return user;
}
findById(id: number): User | undefined {
return this.users.find((u) => u.id === id);
}
findAll(): User[] {
return [...this.users];
}
}
// Responsibility 2: database persistence only
class UserDatabaseService {
save(users: User[]): void {
console.log("Saving to DB:", users);
}
}
// Responsibility 3: email delivery only
class UserEmailService {
sendWelcome(user: User): void {
console.log(`[Email] Sending welcome message to ${user.email}`);
}
sendNotification(user: User, message: string): void {
console.log(`[Email] ${user.email}: ${message}`);
}
}
// Responsibility 4: report generation only
class UserReportService {
generate(users: User[]): string {
return users.map((u) => `${u.id}: ${u.name} <${u.email}>`).join("\n");
}
}
// Orchestration happens in the Application Service layer
class UserApplicationService {
constructor(
private repository: UserRepository,
private dbService: UserDatabaseService,
private emailService: UserEmailService
) {}
registerUser(name: string, email: string): User {
const user = this.repository.add(name, email);
this.dbService.save(this.repository.findAll());
this.emailService.sendWelcome(user);
return user;
}
}
O — Open/Closed Principle
"Software entities should be open for extension but closed for modification."
When adding a new feature, you should add new code, not modify existing code.
Violation Example
// Bad: every new discount type requires modifying this function
type DiscountType = "none" | "percentage" | "fixed" | "buy2get1";
function calculateDiscount(price: number, type: DiscountType): number {
if (type === "none") {
return price;
} else if (type === "percentage") {
return price * 0.9; // 10% off (hard-coded)
} else if (type === "fixed") {
return price - 10; // $10 off (hard-coded)
} else if (type === "buy2get1") {
return price * (2 / 3);
}
// Adding a new type requires modifying this function — OCP violation
return price;
}
Refactored — Open for Extension via Abstraction
// Good: adding a new discount policy requires no changes to existing code
interface DiscountPolicy {
apply(price: number): number;
describe(): string;
}
class NoDiscount implements DiscountPolicy {
apply(price: number): number { return price; }
describe(): string { return "No discount"; }
}
class PercentageDiscount implements DiscountPolicy {
constructor(private percent: number) {}
apply(price: number): number { return price * (1 - this.percent / 100); }
describe(): string { return `${this.percent}% off`; }
}
class FixedDiscount implements DiscountPolicy {
constructor(private amount: number) {}
apply(price: number): number { return Math.max(0, price - this.amount); }
describe(): string { return `$${this.amount} off`; }
}
// Added later — no existing code is modified
class BuyTwoGetOneFree implements DiscountPolicy {
apply(price: number): number { return price * (2 / 3); }
describe(): string { return "Buy 2 Get 1 Free"; }
}
class SeasonalDiscount implements DiscountPolicy {
constructor(private season: "spring" | "summer" | "fall" | "winter") {}
apply(price: number): number {
const rates: Record<string, number> = {
spring: 0.95, summer: 0.85, fall: 0.9, winter: 0.8
};
return price * rates[this.season];
}
describe(): string { return `Seasonal discount (${this.season})`; }
}
class PriceCalculator {
calculate(price: number, policy: DiscountPolicy): number {
const discounted = policy.apply(price);
console.log(`${policy.describe()}: $${price} → $${discounted}`);
return discounted;
}
}
const calculator = new PriceCalculator();
calculator.calculate(100, new PercentageDiscount(10)); // 10% off
calculator.calculate(100, new SeasonalDiscount("summer")); // new policy — no existing code changed
L — Liskov Substitution Principle
"Subtypes must be substitutable for their base types."
Code that uses a base class must work correctly when given a subclass instead. If a subclass violates the contract (preconditions, postconditions, invariants) of the parent, it violates LSP.
Violation Example
// Bad: the classic Square–Rectangle problem (canonical LSP violation)
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(w: number): void { this.width = w; }
setHeight(h: number): void { this.height = h; }
area(): number { return this.width * this.height; }
}
class Square extends Rectangle {
constructor(size: number) { super(size, size); }
// LSP violation: changes the parent's contract.
// Rectangle guarantees that width and height are independent,
// but Square forces them to be equal.
setWidth(w: number): void {
this.width = w;
this.height = w; // also changes height
}
setHeight(h: number): void {
this.width = h; // also changes width
this.height = h;
}
}
function testRectangle(rect: Rectangle): void {
rect.setWidth(5);
rect.setHeight(10);
// Any Rectangle is expected to produce area 50 here
console.assert(rect.area() === 50, `Expected 50 but got ${rect.area()}`);
}
testRectangle(new Rectangle(1, 1)); // OK: 50
testRectangle(new Square(1)); // FAILS: 100 (setWidth also set height to 5, then setHeight set both to 10)
Refactored — Redesigned Inheritance Hierarchy
// Good: extract a shared abstraction into an interface
interface Shape2D {
area(): number;
perimeter(): number;
}
// Rectangle and Square as independent classes
class Rectangle2D implements Shape2D {
constructor(public readonly width: number, public readonly height: number) {}
area(): number { return this.width * this.height; }
perimeter(): number { return 2 * (this.width + this.height); }
}
class Square2D implements Shape2D {
constructor(public readonly side: number) {}
area(): number { return this.side ** 2; }
perimeter(): number { return 4 * this.side; }
}
// Both classes fully honor the Shape2D contract
function printShapeInfo(shape: Shape2D): void {
console.log(`Area: ${shape.area()}, Perimeter: ${shape.perimeter()}`);
}
printShapeInfo(new Rectangle2D(5, 10)); // Area: 50
printShapeInfo(new Square2D(5)); // Area: 25
// Correct use of LSP through inheritance
abstract class Bird {
abstract name(): string;
move(): string { return `${this.name()} is moving.`; }
}
abstract class FlyingBird extends Bird {
fly(): string { return `${this.name()} is flying.`; }
}
abstract class SwimmingBird extends Bird {
swim(): string { return `${this.name()} is swimming.`; }
}
class Eagle extends FlyingBird {
name(): string { return "Eagle"; }
}
class Penguin extends SwimmingBird {
name(): string { return "Penguin"; }
}
// Penguin is never forced to implement fly() — LSP is honored
I — Interface Segregation Principle
"Clients should not be forced to depend on interfaces they do not use."
Many small, focused interfaces are better than one large general-purpose interface.
Violation Example
// Bad: everything in one interface — ISP violation
interface Worker {
work(): void;
eat(): void;
sleep(): void;
attendMeeting(): void;
writeCode(): void;
designUI(): void;
managePeople(): void;
}
// A backend developer doesn't need designUI or managePeople,
// but is forced to implement them.
class BackendDeveloper implements Worker {
work(): void { console.log("Building APIs"); }
eat(): void { console.log("Having lunch"); }
sleep(): void { console.log("Sleeping"); }
attendMeeting(): void { console.log("Attending meeting"); }
writeCode(): void { console.log("Writing code"); }
designUI(): void { throw new Error("Cannot design UI!"); } // ISP violation
managePeople(): void { throw new Error("Cannot manage people!"); } // ISP violation
}
Refactored — Segregated Interfaces
// Good: one interface per role
interface BasicWorker {
work(): void;
eat(): void;
sleep(): void;
}
interface MeetingParticipant {
attendMeeting(): void;
}
interface Coder {
writeCode(): void;
}
interface UIDesigner {
designUI(): void;
}
interface PeopleManager {
managePeople(): void;
}
// Each role implements only the interfaces it needs
class BackendDev implements BasicWorker, MeetingParticipant, Coder {
work(): void { console.log("Building APIs"); }
eat(): void { console.log("Having lunch"); }
sleep(): void { console.log("Sleeping"); }
attendMeeting(): void { console.log("Attending meeting"); }
writeCode(): void { console.log("Writing backend code"); }
}
class Designer implements BasicWorker, MeetingParticipant, UIDesigner {
work(): void { console.log("Designing interfaces"); }
eat(): void { console.log("Having lunch"); }
sleep(): void { console.log("Sleeping"); }
attendMeeting(): void { console.log("Attending meeting"); }
designUI(): void { console.log("Designing screens"); }
}
class TeamLead implements BasicWorker, MeetingParticipant, Coder, PeopleManager {
work(): void { console.log("Reviewing code and managing the team"); }
eat(): void { console.log("Team lunch"); }
sleep(): void { console.log("Sleeping"); }
attendMeeting(): void { console.log("Leading meetings"); }
writeCode(): void { console.log("Writing critical code"); }
managePeople(): void { console.log("Managing team performance"); }
}
// Functions only require the capability they actually need
function runCodeReview(coder: Coder): void {
coder.writeCode();
}
function scheduleDesignReview(designer: UIDesigner): void {
designer.designUI();
}
const dev = new BackendDev();
const lead = new TeamLead();
runCodeReview(dev); // OK
runCodeReview(lead); // OK
D — Dependency Inversion Principle
"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."
Violation Example
// Bad: high-level module depends directly on concrete classes
class MySQLDatabase {
save(data: string): void {
console.log(`Saving to MySQL: ${data}`);
}
find(id: number): string {
return `MySQL record ${id}`;
}
}
class FileLogger {
log(message: string): void {
console.log(`Writing to file: ${message}`);
}
}
// OrderService directly depends on MySQLDatabase and FileLogger.
// Switching to PostgreSQL requires modifying OrderService — DIP violation.
class OrderService {
private db = new MySQLDatabase(); // depends on a concrete class
private logger = new FileLogger(); // depends on a concrete class
placeOrder(orderId: number, product: string): void {
this.logger.log(`Order started: ${orderId}`);
this.db.save(`Order ${orderId}: ${product}`);
this.logger.log(`Order complete: ${orderId}`);
}
}
Refactored — Depend on Abstractions
// Good: depend on interfaces (abstractions)
interface Database {
save(data: string): void;
find(id: number): string;
}
interface Logger {
log(message: string): void;
error(message: string): void;
}
// Concrete implementations: implement the Database interface
class MySQLAdapter implements Database {
save(data: string): void { console.log(`MySQL save: ${data}`); }
find(id: number): string { return `MySQL record ${id}`; }
}
class PostgreSQLAdapter implements Database {
save(data: string): void { console.log(`PostgreSQL save: ${data}`); }
find(id: number): string { return `PostgreSQL record ${id}`; }
}
class InMemoryDatabase implements Database {
private store: Map<string, string> = new Map();
save(data: string): void {
const id = Date.now().toString();
this.store.set(id, data);
console.log(`In-memory save: ${data}`);
}
find(id: number): string {
return this.store.get(id.toString()) ?? "Not Found";
}
}
// Concrete implementations: implement the Logger interface
class ConsoleLogger implements Logger {
log(message: string): void { console.log(`[INFO] ${message}`); }
error(message: string): void { console.error(`[ERROR] ${message}`); }
}
class FileLogger2 implements Logger {
log(message: string): void { console.log(`[FILE] ${message}`); }
error(message: string): void { console.error(`[FILE ERROR] ${message}`); }
}
// High-level module: depends only on interfaces — knows nothing about concrete classes
class OrderService2 {
constructor(
private db: Database, // depends on abstraction
private logger: Logger // depends on abstraction
) {}
placeOrder(orderId: number, product: string): void {
this.logger.log(`Order started: ${orderId}`);
this.db.save(`Order ${orderId}: ${product}`);
this.logger.log(`Order complete: ${orderId}`);
}
}
// Dependency injection: concrete implementations provided from outside
const productionService = new OrderService2(
new MySQLAdapter(),
new ConsoleLogger()
);
const testService = new OrderService2(
new InMemoryDatabase(), // use in-memory DB for tests
new ConsoleLogger()
);
productionService.placeOrder(1001, "TypeScript Handbook");
testService.placeOrder(9999, "Test Product");
Practical Example: Order Processing System Applying All Five Principles
// ==================== Interface Definitions (DIP + ISP) ====================
// ISP: each interface contains only what its role requires
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
findByCustomer(customerId: string): Promise<Order[]>;
}
interface PaymentGateway {
charge(amount: number, customerId: string): Promise<PaymentResult>;
refund(paymentId: string): Promise<void>;
}
interface NotificationService {
notify(customerId: string, message: string): Promise<void>;
}
interface InventoryService {
checkStock(productId: string): Promise<number>;
reserve(productId: string, quantity: number): Promise<void>;
release(productId: string, quantity: number): Promise<void>;
}
// ==================== Domain Model (SRP) ====================
type OrderStatus = "pending" | "confirmed" | "shipped" | "delivered" | "cancelled";
interface OrderItem {
productId: string;
name: string;
quantity: number;
unitPrice: number;
}
interface PaymentResult {
paymentId: string;
success: boolean;
errorMessage?: string;
}
class Order {
readonly id: string;
readonly customerId: string;
private items: OrderItem[];
private status: OrderStatus;
readonly createdAt: Date;
constructor(customerId: string, items: OrderItem[]) {
this.id = `ORD-${Date.now()}`;
this.customerId = customerId;
this.items = [...items];
this.status = "pending";
this.createdAt = new Date();
}
getStatus(): OrderStatus { return this.status; }
getItems(): OrderItem[] { return [...this.items]; }
confirm(): void {
if (this.status !== "pending") {
throw new Error(`Cannot confirm an order with status: ${this.status}`);
}
this.status = "confirmed";
}
cancel(): void {
if (this.status === "shipped" || this.status === "delivered") {
throw new Error("Cannot cancel an order that has been shipped or delivered.");
}
this.status = "cancelled";
}
totalAmount(): number {
return this.items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
}
}
// ==================== Discount Policies (OCP) ====================
interface DiscountPolicy2 {
apply(order: Order): number;
describe(): string;
}
class NoDiscount2 implements DiscountPolicy2 {
apply(order: Order): number { return order.totalAmount(); }
describe(): string { return "No discount"; }
}
class MemberDiscount implements DiscountPolicy2 {
constructor(private discountRate: number) {}
apply(order: Order): number {
return order.totalAmount() * (1 - this.discountRate);
}
describe(): string { return `Member ${this.discountRate * 100}% discount`; }
}
class VIPDiscount implements DiscountPolicy2 {
apply(order: Order): number {
const total = order.totalAmount();
return total > 500 ? total * 0.8 : total * 0.9;
}
describe(): string { return "VIP discount (20% over $500, 10% otherwise)"; }
}
// ==================== Order Service (SRP + DIP) ====================
class OrderPlacementService {
// DIP: depends on interfaces, not concrete classes
constructor(
private repository: OrderRepository,
private payment: PaymentGateway,
private notification: NotificationService,
private inventory: InventoryService,
private discountPolicy: DiscountPolicy2 // OCP: swappable
) {}
async placeOrder(
customerId: string,
items: OrderItem[]
): Promise<Order> {
// SRP: each step is handled by a separate method
await this.validateStock(items);
const order = new Order(customerId, items);
await this.processPayment(order);
await this.repository.save(order);
await this.notification.notify(
customerId,
`Your order has been placed. Order ID: ${order.id}`
);
return order;
}
private async validateStock(items: OrderItem[]): Promise<void> {
for (const item of items) {
const stock = await this.inventory.checkStock(item.productId);
if (stock < item.quantity) {
throw new Error(`Insufficient stock for product ${item.productId} (available: ${stock})`);
}
}
}
private async processPayment(order: Order): Promise<void> {
const discountedAmount = this.discountPolicy.apply(order);
console.log(
`Payment: $${order.totalAmount()} → $${discountedAmount} (${this.discountPolicy.describe()})`
);
const result = await this.payment.charge(discountedAmount, order.customerId);
if (!result.success) {
throw new Error(`Payment failed: ${result.errorMessage}`);
}
order.confirm();
}
}
// ==================== Concrete Implementations (LSP compliant) ====================
// LSP: every implementation fully honors the interface contract
class InMemoryOrderRepository implements OrderRepository {
private store = new Map<string, Order>();
async save(order: Order): Promise<void> {
this.store.set(order.id, order);
}
async findById(id: string): Promise<Order | null> {
return this.store.get(id) ?? null;
}
async findByCustomer(customerId: string): Promise<Order[]> {
return [...this.store.values()].filter((o) => o.customerId === customerId);
}
}
class MockPaymentGateway implements PaymentGateway {
async charge(amount: number, customerId: string): Promise<PaymentResult> {
console.log(`[Payment] Charging $${amount} to customer ${customerId}`);
return { paymentId: `PAY-${Date.now()}`, success: true };
}
async refund(paymentId: string): Promise<void> {
console.log(`[Payment] Refunding ${paymentId}`);
}
}
class EmailNotificationService implements NotificationService {
async notify(customerId: string, message: string): Promise<void> {
console.log(`[Email] ${customerId}: ${message}`);
}
}
class MockInventoryService implements InventoryService {
private stock = new Map<string, number>([
["TS-BOOK", 50],
["TS-COURSE", 100],
]);
async checkStock(productId: string): Promise<number> {
return this.stock.get(productId) ?? 0;
}
async reserve(productId: string, quantity: number): Promise<void> {
const current = this.stock.get(productId) ?? 0;
this.stock.set(productId, current - quantity);
}
async release(productId: string, quantity: number): Promise<void> {
const current = this.stock.get(productId) ?? 0;
this.stock.set(productId, current + quantity);
}
}
// ==================== Assembly and Execution ====================
async function main() {
// Standard member order
const memberService = new OrderPlacementService(
new InMemoryOrderRepository(),
new MockPaymentGateway(),
new EmailNotificationService(),
new MockInventoryService(),
new MemberDiscount(0.1) // 10% member discount
);
const order = await memberService.placeOrder("CUST-001", [
{ productId: "TS-BOOK", name: "TypeScript Handbook", quantity: 2, unitPrice: 35 },
{ productId: "TS-COURSE", name: "Online Course", quantity: 1, unitPrice: 59 },
]);
console.log(`Order placed: ${order.id}, status: ${order.getStatus()}`);
}
main();
Pro Tips
The Limits of SOLID and the Danger of Over-Abstraction
SOLID is a set of guidelines, not absolute laws. Blindly following them can make code more complex — this is the "Architecture Astronaut" problem.
// Over-abstraction: unnecessary interface for a simple utility function
// This is an example of what NOT to do
interface StringLengthCalculator {
calculate(str: string): number;
}
class StandardStringLengthCalculator implements StringLengthCalculator {
calculate(str: string): number { return str.length; }
}
// Don't do the above — just write this instead:
function getLength(str: string): number { return str.length; }
// Pragmatic approach: abstract only when change is likely
// - DB implementations: likely to swap later → interfaces are appropriate
// - String length: never going to change → just use a function
YAGNI (You Aren't Gonna Need It)
"Don't build it until you actually need it." Over-predicting the future and creating premature abstractions makes current code harder to understand. It is better to wait until a change actually happens and refactor then.
Priority Order for Applying the Principles
In practice, applying the principles in this order is most effective.
- SRP first: if a class is too big, split it. Testability improves immediately.
- DIP: place interfaces at the boundaries with external systems (DB, APIs, files).
- OCP: apply to logic that changes frequently (discounts, notifications, sorting).
- ISP: split interfaces when they start growing too large.
- LSP: review inheritance whenever you use it to ensure contracts are honored.
Summary
| Principle | Acronym | Key Question | TypeScript Application |
|---|---|---|---|
| Single Responsibility | SRP | Does this class have only one reason to change? | Class separation, separation of concerns |
| Open/Closed | OCP | Does adding a new feature require modifying existing code? | Interfaces + polymorphism |
| Liskov Substitution | LSP | Can a subclass fully replace its parent? | Honor abstract class / interface contracts |
| Interface Segregation | ISP | Are any unused methods being implemented? | Split into smaller interfaces |
| Dependency Inversion | DIP | Is there a direct dependency on a concrete class? | Constructor injection, interface dependency |
In the next chapter we learn TypeScript Generics — how to make types into parameters to design reusable components, apply generic constraints, and combine them with conditional types, all through practical examples.