SOLID is a set of five design principles that make codebases easier to extend, test, and maintain. Below are crisp explanations and pragmatic TypeScript examples for each principle.
S - Single Responsibility Principle (SRP)
A class/module should have one reason to change.
Anti-pattern: a UserService that both manages users and sends emails.
// ❌ Violates SRP
class UserService {
constructor(private db: Map<string, string>) {}
createUser(id: string, email: string) {
this.db.set(id, email)
this.sendWelcomeEmail(email)
}
private sendWelcomeEmail(email: string) {
// email delivery logic tangled with persistence
console.log(`Welcome email sent to ${email}`)
}
}
Refactor: split responsibilities into UserRepository and EmailService. A thin coordinator composes them.
// ✅ SRP-compliant
class UserRepository {
constructor(private db: Map<string, string>) {}
save(id: string, email: string) {
self.db.set(id, email)
}
}
class EmailService {
sendWelcome(email: string) {
console.log(`Welcome email sent to ${email}`)
}
}
class UserOnboarding {
constructor(private repo: UserRepository, private mailer: EmailService) {}
createUser(id: string, email: string) {
this.repo.save(id, email)
this.mailer.sendWelcome(email)
}
}
O - Open/Closed Principle (OCP)
Software should be open for extension but closed for modification.
Use polymorphism or composition to add behavior without editing stable code.
// ✅ Strategy pattern for discounts
interface Discount {
apply(amount: number): number
}
class NoDiscount implements Discount {
apply(amount: number) {
return amount
}
}
class PercentageDiscount implements Discount {
constructor(private pct: number) {}
apply(amount: number) {
return amount * (1 - this.pct)
}
}
class FixedDiscount implements Discount {
constructor(private value: number) {}
apply(amount: number) {
return Math.max(0, amount - this.value)
}
}
class Checkout {
constructor(private discount: Discount) {}
total(amount: number) {
return this.discount.apply(amount)
}
}
// Extend with a *new* rule, no edits to Checkout:
class TieredDiscount implements Discount {
apply(amount: number) {
if (amount >= 500) return amount * 0.8
if (amount >= 200) return amount * 0.9
return amount
}
}
L - Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types without breaking expectations.
The classic Rectangle/Square pitfall: a Square that overrides setters changes expected behavior.
// ❌ LSP violation
class Rectangle {
constructor(protected _w: number, protected _h: number) {}
set width(w: number) {
this._w = w
}
set height(h: number) {
this._h = h
}
get area() {
return this._w * this._h
}
}
class Square extends Rectangle {
// Forces width === height; breaks clients that expect independent setters
set width(w: number) {
this._w = this._h = w
}
set height(h: number) {
this._w = this._h = h
}
}
function resizeWide(r: Rectangle) {
r.width = 10
r.height = 5
// Caller expects area === 50; with Square it becomes 25 or 100 unexpectedly
}
Fix: model invariants explicitly; avoid inheriting where constraints differ. Prefer composition or a common interface that doesn’t expose conflicting mutators.
// ✅ LSP-compliant
interface Shape {
area(): number
}
class Rect implements Shape {
constructor(private w: number, private h: number) {}
area() {
return this.w * this.h
}
}
class Sq implements Shape {
constructor(private side: number) {}
area() {
return this.side * this.side
}
}
function reportArea(s: Shape) {
return s.area() // Any Shape can substitute safely
}
I - Interface Segregation Principle (ISP)
Clients shouldn’t be forced to depend on methods they don’t use.
Split “fat” interfaces into focused ones.
// ❌ Fat interface
interface MultiFunctionDevice {
print(doc: string): void
scan(): string
fax(number: string, doc: string): void
}
class SimplePrinter implements MultiFunctionDevice {
// Forced to implement useless members
print(doc: string) {
console.log(`Printing: ${doc}`)
}
scan() {
throw new Error('Not supported')
}
fax() {
throw new Error('Not supported')
}
}
Refactor: smaller, role-specific interfaces.
// ✅ Segregated interfaces
interface Printer {
print(doc: string): void
}
interface Scanner {
scan(): string
}
interface Fax {
fax(number: string, doc: string): void
}
class BasicPrinter implements Printer {
print(doc: string) {
console.log(`Printing: ${doc}`)
}
}
class AllInOne implements Printer, Scanner, Fax {
print(doc: string) {
/* ... */
}
scan() {
return 'scanned-bytes'
}
fax(number: string, doc: string) {
/* ... */
}
}
// Clients depend only on what they use:
function printReport(p: Printer, doc: string) {
p.print(doc)
}
D - Dependency Inversion Principle (DIP)
High-level modules depend on abstractions, not concrete implementations.
Prefer constructor injection and small interfaces.
// Abstraction
interface Logger {
info(msg: string): void
error(msg: string, err?: unknown): void
}
// Low-level details
class ConsoleLogger implements Logger {
info(msg: string) {
console.log(`[INFO] ${msg}`)
}
error(msg: string, err?: unknown) {
console.error(`[ERROR] ${msg}`, err)
}
}
// High-level policy depends on Logger interface, not a concrete console
class PaymentService {
constructor(private logger: Logger) {}
charge(customerId: string, amount: number) {
try {
// ... billing logic ...
this.logger.info(`Charged ${customerId} $${amount.toFixed(2)}`)
} catch (e) {
this.logger.error(`Charge failed for ${customerId}`, e)
throw e
}
}
}
// Wiring (composition root)
const service = new PaymentService(new ConsoleLogger())
service.charge('cust_123', 49.99)
Putting It Together
- SRP keeps modules focused.
- OCP makes adding features safe.
- LSP protects contracts and expectations.
- ISP keeps dependencies lean.
- DIP decouples policy from details, enabling testability and swapping implementations.
Use these small patterns consistently in your TypeScript projects to reduce regressions and increase your ability to evolve features with confidence.