SOLID Principles in TypeScript with Practical Examples
Apply the five SOLID principles to TypeScript code to improve maintainability, testability, and reduce coupling in object-oriented designs
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
SOLID Principles in TypeScript with Practical Examples
The SOLID principles provide a framework for writing maintainable object-oriented code. When applied to TypeScript, they help prevent the common pitfalls of tightly coupled classes, brittle inheritance hierarchies, and unmaintainable dependency graphs.
When to Use This
- Classes grow beyond 200 lines and handle multiple responsibilities
- Changing one feature requires modifying unrelated code
- Unit tests require extensive mocking of concrete dependencies
S — Single Responsibility Principle
A class should have one reason to change. When a class handles both data access and business logic, changes to the database schema force retesting of business rules.
// Before: OrderService handles validation, persistence, and notifications
class OrderService {
async createOrder(data: OrderData) {
if (!this.validate(data)) throw new Error('Invalid');
await this.db.query('INSERT INTO orders ...');
await this.sendEmail(data.customerEmail);
}
}
// After: Separate responsibilities
class OrderValidator {
validate(data: OrderData): boolean {
return !!data.items?.length && data.total > 0;
}
}
class OrderRepository {
async save(order: OrderData): Promise<Order> {
// Database logic only
}
}
class OrderNotificationService {
async sendConfirmation(email: string, order: Order): Promise<void> {
// Email logic only
}
}
O — Open/Closed Principle
Software entities should be open for extension but closed for modification. Use composition and interfaces instead of modifying existing code. See Strategy and Decorator for practical examples.
interface PaymentProcessor {
process(amount: number): Promise<PaymentResult>;
}
class StripeProcessor implements PaymentProcessor {
async process(amount: number) {
// Stripe-specific logic
return { success: true, transactionId: 'stripe_123' };
}
}
class PayPalProcessor implements PaymentProcessor {
async process(amount: number) {
// PayPal-specific logic
return { success: true, transactionId: 'paypal_456' };
}
}
// Checkout does not change when adding new processors
class CheckoutService {
constructor(private processor: PaymentProcessor) {}
async charge(amount: number) {
return this.processor.process(amount);
}
}
L — Liskov Substitution Principle
Subtypes must be substitutable for their base types without altering program correctness.
// Violation: PremiumCustomer breaks the contract of Customer
class Customer {
getDiscount(): number { return 0; }
}
class PremiumCustomer extends Customer {
getDiscount(): number { return 0.2; }
}
// Correct: Both satisfy the same contract
interface Discountable {
getDiscount(): number;
}
class RegularCustomer implements Discountable {
getDiscount() { return 0; }
}
class PremiumCustomer implements Discountable {
getDiscount() { return 0.2; }
}
function calculatePrice(base: number, customer: Discountable) {
return base * (1 - customer.getDiscount());
}
I — Interface Segregation Principle
Clients should not depend on interfaces they do not use. Split large interfaces into focused ones.
// Before: Printer interface forces Fax capability
interface MultiFunctionDevice {
print(document: string): void;
scan(): string;
fax(document: string): void;
}
// After: Segregated interfaces
interface Printer {
print(document: string): void;
}
interface Scanner {
scan(): string;
}
interface Fax {
fax(document: string): void;
}
class SimplePrinter implements Printer {
print(document: string) {
console.log(`Printing: ${document}`);
}
}
class AllInOne implements Printer, Scanner, Fax {
print(document: string) { /* ... */ }
scan() { return 'scanned'; }
fax(document: string) { /* ... */ }
}
D — Dependency Inversion Principle
Depend on abstractions, not concrete implementations. Use constructor injection to make dependencies explicit and testable. See Dependency Injection for wiring patterns.
interface Logger {
log(message: string): void;
}
class ConsoleLogger implements Logger {
log(message: string) { console.log(message); }
}
class FileLogger implements Logger {
log(message: string) { /* write to file */ }
}
class UserService {
constructor(private logger: Logger) {}
createUser(data: UserData) {
// Business logic
this.logger.log(`User created: ${data.email}`);
}
}
// Tests inject a mock logger
class MockLogger implements Logger {
messages: string[] = [];
log(message: string) { this.messages.push(message); }
}
How It Works
- Single Responsibility isolates change impact to one class
- Open/Closed allows feature addition without regression risk
- Liskov Substitution ensures inheritance hierarchies remain safe
- Interface Segregation prevents fat interfaces and forced dependencies
- Dependency Inversion enables unit testing and framework swapping
Production Considerations
- Use dependency injection containers like TSyringe or InversifyJS for large applications
- Apply SOLID incrementally; refactoring everything at once is risky
- Combine with the Composition Root pattern to wire dependencies at application startup
Common Mistakes
- Creating one interface per class (over-engineering)
- Using inheritance when composition is sufficient
- Injecting concrete classes instead of interfaces in constructors
FAQ
Q: Does SOLID apply to functional programming? A: Partially. SRP and DIP translate well. OCP and LSP are less relevant when using pure functions instead of classes.
Q: Should every class implement an interface? A: No. Extract interfaces only when there are multiple implementations or when testing requires mocking.
Is this pattern suitable for small projects?
For small projects with few components, this pattern may add unnecessary complexity. Start simple and introduce the pattern when you feel the pain it solves.
How does this pattern compare to alternatives?
Each pattern makes different trade-offs. Review the variants table above and consider your specific constraints: team size, performance requirements, and future scaling plans.
Can I partially apply this pattern?
Yes. Many teams adopt patterns incrementally. Start with the core idea and add sophistication as needed. The pattern is a guide, not a strict blueprint.
Advanced Topics
Scenario: SOLID in an Order Service
// SRP: each class has one reason to change
class Order {
constructor(public id: string, public items: OrderItem[], public total: number) {}
}
class OrderRepository {
async save(order: Order): Promise<void> { /* DB save */ }
async findById(id: string): Promise<Order | null> { /* DB query */ }
}
class OrderValidator {
validate(order: Order): string[] {
const errors: string[] = [];
if (order.items.length === 0) errors.push("Order must have items");
if (order.total <= 0) errors.push("Total must be positive");
return errors;
}
}
class OrderNotifier {
async notify(order: Order): Promise<void> {
await emailService.send(order.customerEmail, "Order confirmed");
}
}
// OCP: open for extension, closed for modification
interface DiscountStrategy {
apply(order: Order): number;
}
class NoDiscount implements DiscountStrategy {
apply(order: Order): number { return order.total; }
}
class PercentageDiscount implements DiscountStrategy {
constructor(private percent: number) {}
apply(order: Order): number { return order.total * (1 - this.percent / 100); }
}
// Add new discount without touching existing code
class BlackFridayDiscount implements DiscountStrategy {
apply(order: Order): number { return order.total * 0.7; }
}
// LSP: subclasses must be substitutable for superclass
// ISP: small, specific interfaces
interface Readable { read(id: string): Promise<Order | null>; }
interface Writable { write(order: Order): Promise<void>; }
// OrderRepository implements both, but ReadOnlyRepo only Readable
// DIP: depend on abstractions, not concretions
class OrderService {
constructor(
private repo: Writable,
private validator: OrderValidator,
private notifier: OrderNotifier,
private discount: DiscountStrategy
) {}
async createOrder(order: Order): Promise<void> {
const errors = this.validator.validate(order);
if (errors.length > 0) throw new Error(errors.join(", "));
order.total = this.discount.apply(order);
await this.repo.write(order);
await this.notifier.notify(order);
}
}
// Composition: inject dependencies
const service = new OrderService(
new OrderRepository(),
new OrderValidator(),
new OrderNotifier(),
new PercentageDiscount(10)
);
Lessons:
- SRP: Order, Repository, Validator, Notifier are separate responsibilities
- OCP: new discount = new class, do not modify existing ones
- LSP: any DiscountStrategy can replace another
- ISP: Readable and Writable separated, do not force unnecessary methods
- DIP: OrderService depends on interfaces, not concrete classes
- In tests, inject mocks: MockRepo, MockNotifier, NoDiscount
### How do I apply SOLID in legacy code?
Start with SRP: identify classes doing too much and extract responsibilities. Use method and class extraction. Then DIP: introduce interfaces for external dependencies and use injection. OCP comes naturally: when you need to add variation, create a new class instead of modifying. Do not try to apply all principles at once: refactor incrementally, one principle at a time, with tests as a safety net. Related Resources
Adapter Pattern for Integrating External REST APIs
Use the Adapter pattern to normalize responses from external REST APIs into a consistent internal model without leaking third-party formats into your domain
PatternDecorator Pattern for HTTP Request Pipelines
Use the Decorator pattern to compose cross-cutting concerns like logging, metrics, and retries into HTTP request pipelines without modifying core logic
PatternValue Object Pattern
Model domain concepts by value rather than identity. An immutable object defined by its attributes, not by a unique ID.