2025-12-26
SOLID Principles in JavaScript: Practical Guide with TypeScript and React
Learn how SOLID principles apply to modern JavaScript development. Practical examples with TypeScript, React hooks, and functional patterns - plus when to use them and when they're overkill.
SOLID principles were formulated for object-oriented programming, but modern JavaScript development looks different - functional patterns, React hooks, dynamic typing. Do these principles still matter? The answer is yes, but with important adaptations.
These principles remain valuable in JavaScript, but they need translation. The challenge isn’t whether to use them, but how to apply them in a language that favors composition over inheritance and duck typing over rigid interfaces.
Abstract
This post examines how SOLID’s five principles - Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion - adapt to JavaScript and TypeScript development. Through practical examples with React, Node.js, and TypeScript, we’ll explore when these principles improve code quality and when they lead to over-engineering. Each principle includes working code examples, anti-patterns to avoid, and specific guidance for JavaScript’s functional and dynamic nature.
Understanding SOLID in JavaScript Context
The Challenge
SOLID principles were designed for statically-typed, class-based languages like Java and C#. JavaScript brings different characteristics:
- Dynamic typing - No compile-time type checking without TypeScript
- Functional patterns - Functions as first-class citizens
- Composition focus - Less emphasis on inheritance
- Duck typing - Objects validated by behavior, not explicit interfaces
- React patterns - Hooks and components have different constraints
The question isn’t whether SOLID applies to JavaScript, but how to translate these principles effectively.
Single Responsibility Principle (SRP)
Definition: A module should have one reason to change.
This translates most directly to JavaScript. Whether you’re writing classes, functions, or React components, each should handle one concern.
React Component Example
Here’s a common violation - a component handling multiple responsibilities:
// BAD: Violates SRP: Component handles data fetching, state, and rendering
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<img src={user.avatar} alt={user.name} />
</div>
);
}
This component has three reasons to change: data fetching logic, state management, or rendering requirements. Extract each concern:
// Follows SRP: Data fetching responsibility
function useUser(userId: string) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
return { user, loading, error };
}
// Presentation responsibility
function UserProfile({ userId }: { userId: string }) {
const { user, loading, error } = useUser(userId);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!user) return null;
return <UserCard user={user} />;
}
// Rendering responsibility
function UserCard({ user }: { user: User }) {
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<img src={user.avatar} alt={user.name} />
</div>
);
}
Now each piece has one responsibility. The useUser hook can be tested independently and reused in other components. The UserCard renders user data without knowing where it came from. Changes to the API affect only useUser, not the presentation logic.
Common SRP Anti-pattern: God Objects
// BAD: Module with unrelated concerns
// utils/user.js
export function validateEmail(email) { /* ... */ }
export function formatCurrency(amount) { /* ... */ }
export function fetchWeatherData(city) { /* ... */ }
export function compressImage(file) { /* ... */ }
These functions have nothing in common. Split them into focused modules:
// Cohesive modules
// utils/validation.js
export function validateEmail(email) { /* ... */ }
export function validatePassword(password) { /* ... */ }
// utils/formatting.js
export function formatCurrency(amount) { /* ... */ }
export function formatDate(date) { /* ... */ }
// services/weather.js
export function fetchWeatherData(city) { /* ... */ }
Open/Closed Principle (OCP)
Definition: Software entities should be open for extension, closed for modification.
In JavaScript, this means adding new functionality without changing existing code. The strategy pattern works well here.
Payment Processing Example
Here’s the violation - adding new payment methods requires modifying existing code:
// BAD: Violates OCP: Must modify function for each new payment method
function processPayment(amount: number, method: string) {
if (method === 'credit-card') {
validateCreditCard();
chargeCreditCard(amount);
} else if (method === 'paypal') {
validatePayPalAccount();
chargePayPal(amount);
} else if (method === 'crypto') {
validateWallet();
transferCrypto(amount);
}
// Adding new method requires modifying this function
}
The functional approach using strategy pattern:
// Follows OCP: Extend without modification
type PaymentProcessor = {
validate: () => Promise<boolean>;
charge: (amount: number) => Promise<PaymentResult>;
};
const createCreditCardProcessor = (cardDetails: CardDetails): PaymentProcessor => ({
validate: () => validateCreditCard(cardDetails),
charge: (amount) => chargeCreditCard(cardDetails, amount),
});
const createPayPalProcessor = (email: string): PaymentProcessor => ({
validate: () => validatePayPalAccount(email),
charge: (amount) => chargePayPal(email, amount),
});
const processPayment = async (
processor: PaymentProcessor,
amount: number
): Promise<PaymentResult> => {
const isValid = await processor.validate();
if (!isValid) throw new Error('Payment validation failed');
return processor.charge(amount);
};
// Usage - adding new processor requires no changes to processPayment
await processPayment(createCreditCardProcessor(cardDetails), 100);
await processPayment(createPayPalProcessor('[email protected]'), 100);
// Add crypto without modifying existing code
const createCryptoProcessor = (wallet: string): PaymentProcessor => ({
validate: () => validateWallet(wallet),
charge: (amount) => transferCrypto(wallet, amount),
});
await processPayment(createCryptoProcessor(walletAddress), 100);
This works with TypeScript’s structural typing. New processors just need to match the shape - no explicit interface declaration required in JavaScript, though TypeScript helps with compile-time safety.
OCP with Higher-Order Functions
// OCP through composition
type Middleware = (data: any) => any;
const compose = (...fns: Middleware[]) => (data: any) =>
fns.reduce((result, fn) => fn(result), data);
// Base transformations
const toLowerCase = (str: string) => str.toLowerCase();
const trim = (str: string) => str.trim();
const removeSpaces = (str: string) => str.replace(/\s/g, '');
// Extend by composing - no modification needed
const normalizeEmail = compose(trim, toLowerCase);
const normalizeUsername = compose(trim, toLowerCase, removeSpaces);
// Add new transformation
const removeDashes = (str: string) => str.replace(/-/g, '');
const normalizePhoneNumber = compose(trim, removeSpaces, removeDashes);
Liskov Substitution Principle (LSP)
Definition: Objects should be replaceable with their subtypes without breaking the program.
LSP violations are common with inheritance. The classic example:
// BAD: Violates LSP: Square breaks Rectangle's contract
class Rectangle {
constructor(
protected width: number,
protected height: number
) {}
setWidth(width: number) {
this.width = width;
}
setHeight(height: number) {
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
class Square extends Rectangle {
setWidth(width: number) {
this.width = width;
this.height = width; // Unexpected behavior
}
setHeight(height: number) {
this.width = height;
this.height = height; // Unexpected behavior
}
}
// This breaks with Square
function resizeRectangle(rectangle: Rectangle) {
rectangle.setWidth(10);
rectangle.setHeight(5);
console.assert(rectangle.getArea() === 50); // Fails with Square (area = 25)
}
The solution is composition over inheritance:
// Follows LSP: Use composition instead
interface Shape {
getArea(): number;
}
class Rectangle implements Shape {
constructor(
private width: number,
private height: number
) {}
setWidth(width: number) {
this.width = width;
}
setHeight(height: number) {
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
class Square implements Shape {
constructor(private size: number) {}
setSize(size: number) {
this.size = size;
}
getArea(): number {
return this.size * this.size;
}
}
// Functions work with Shape interface - no substitution issues
function printArea(shape: Shape) {
console.log(`Area: ${shape.getArea()}`);
}
printArea(new Rectangle(10, 5)); // Works
printArea(new Square(5)); // Works
LSP in React Components
// BAD: Violates LSP: Enhanced button requires additional prop
interface ButtonProps {
onClick: () => void;
label: string;
}
function IconButton({ onClick, label, icon }: ButtonProps & { icon: string }) {
if (!icon) {
throw new Error('IconButton requires icon prop'); // BAD: Breaks substitution
}
return (
<button onClick={onClick}>
<Icon name={icon} />
{label}
</button>
);
}
// Follows LSP: Optional enhancement
interface IconButtonProps extends ButtonProps {
icon?: string; // Optional - doesn't break substitution
}
function IconButton({ onClick, label, icon }: IconButtonProps) {
return (
<button onClick={onClick}>
{icon && <Icon name={icon} />}
{label}
</button>
);
}
// Can substitute IconButton anywhere Button is used
function Form() {
const handleSubmit = () => console.log('Submitted');
return (
<>
<Button onClick={handleSubmit} label="Submit" />
<IconButton onClick={handleSubmit} label="Submit" icon="check" />
</>
);
}
Interface Segregation Principle (ISP)
Definition: Clients shouldn’t depend on interfaces they don’t use.
JavaScript’s duck typing naturally supports this, but TypeScript interfaces and React props benefit from explicit segregation.
The Fat Interface Problem
// BAD: Violates ISP: Fat interface forces unused method implementations
interface Worker {
work(): void;
eat(): void;
sleep(): void;
getMaintenance(): void;
}
class HumanWorker implements Worker {
work() { console.log('Working...'); }
eat() { console.log('Eating lunch...'); }
sleep() { console.log('Sleeping...'); }
getMaintenance() {
throw new Error('Humans do not need maintenance'); // BAD: Forced to implement
}
}
class RobotWorker implements Worker {
work() { console.log('Working...'); }
getMaintenance() { console.log('Getting maintenance...'); }
eat() {
throw new Error('Robots do not eat'); // BAD: Forced to implement
}
sleep() {
throw new Error('Robots do not sleep'); // BAD: Forced to implement
}
}
Split into focused interfaces:
// Follows ISP: Segregated interfaces
interface Workable {
work(): void;
}
interface Eatable {
eat(): void;
}
interface Sleepable {
sleep(): void;
}
interface Maintainable {
getMaintenance(): void;
}
// Implement only needed interfaces
class HumanWorker implements Workable, Eatable, Sleepable {
work() { console.log('Working...'); }
eat() { console.log('Eating lunch...'); }
sleep() { console.log('Sleeping...'); }
}
class RobotWorker implements Workable, Maintainable {
work() { console.log('Working...'); }
getMaintenance() { console.log('Getting maintenance...'); }
}
// Functions depend only on what they need
function makeWork(worker: Workable) {
worker.work();
}
function feedWorker(worker: Eatable) {
worker.eat();
}
const human = new HumanWorker();
const robot = new RobotWorker();
makeWork(human); // Works
makeWork(robot); // Works
feedWorker(human); // Works
// feedWorker(robot); // BAD: Compile error - Robot doesn't implement Eatable
ISP in React Components
// BAD: Violates ISP: Component depends on entire User type
interface User {
id: string;
name: string;
email: string;
address: Address;
phoneNumber: string;
preferences: UserPreferences;
billingInfo: BillingInfo;
// ... 20+ more properties
}
function UserGreeting({ user }: { user: User }) {
return <h1>Hello, {user.name}!</h1>; // Only uses name
}
// Follows ISP: Component depends only on what it uses
interface UserGreetingProps {
name: string;
}
function UserGreeting({ name }: UserGreetingProps) {
return <h1>Hello, {name}!</h1>;
}
// Usage: Pass only needed data
function App() {
const user: User = fetchUser();
return <UserGreeting name={user.name} />;
}
TypeScript Utility Types for ISP
// Large interface
interface User {
id: string;
email: string;
password: string;
firstName: string;
lastName: string;
address: Address;
phoneNumber: string;
createdAt: Date;
}
// Create focused types using utility types
type UserCredentials = Pick<User, 'email' | 'password'>;
type UserProfile = Pick<User, 'id' | 'firstName' | 'lastName' | 'email'>;
type UserContactInfo = Pick<User, 'email' | 'phoneNumber' | 'address'>;
type PublicUser = Omit<User, 'password'>;
// Components receive only needed properties
function UserContactForm({ email, phoneNumber, address }: UserContactInfo) {
// Component logic
}
Dependency Inversion Principle (DIP)
Definition: High-level modules shouldn’t depend on low-level modules. Both should depend on abstractions.
This is critical for testability and flexibility.
The Problem: Direct Dependencies
// BAD: Violates DIP: UserService directly depends on concrete implementations
class MySQLDatabase {
connect() { /* ... */ }
query(sql: string) { /* ... */ }
}
class EmailService {
sendEmail(to: string, subject: string, body: string) {
// Uses Gmail SMTP directly
}
}
class UserService {
private db = new MySQLDatabase(); // BAD: Tight coupling
private emailService = new EmailService(); // BAD: Tight coupling
async createUser(userData: UserData) {
await this.db.connect();
const user = await this.db.query('INSERT INTO users...');
await this.emailService.sendEmail(user.email, 'Welcome', 'Welcome!');
return user;
}
}
// Cannot test without real MySQL and email service
// Cannot switch implementations
Use constructor injection with abstractions:
// Follows DIP: Depend on abstractions
interface Database {
connect(): Promise<void>;
query<T>(sql: string, params?: any[]): Promise<T>;
}
interface EmailProvider {
send(to: string, subject: string, body: string): Promise<void>;
}
// Concrete implementations
class MySQLDatabase implements Database {
async connect() { /* MySQL connection */ }
async query<T>(sql: string, params?: any[]): Promise<T> {
/* MySQL query */
return {} as T;
}
}
class PostgreSQLDatabase implements Database {
async connect() { /* PostgreSQL connection */ }
async query<T>(sql: string, params?: any[]): Promise<T> {
/* PostgreSQL query */
return {} as T;
}
}
// High-level module depends on abstractions
class UserService {
constructor(
private db: Database,
private emailProvider: EmailProvider
) {}
async createUser(userData: UserData) {
await this.db.connect();
const user = await this.db.query<User>('INSERT INTO users...', [userData]);
await this.emailProvider.send(user.email, 'Welcome', 'Welcome!');
return user;
}
}
// Production: Inject real implementations
const userService = new UserService(
new MySQLDatabase(),
new GmailEmailProvider()
);
// Testing: Inject mocks
const testUserService = new UserService(
new MockDatabase(),
new MockEmailProvider()
);
// Easy to switch implementations
const productionUserService = new UserService(
new PostgreSQLDatabase(),
new SendGridEmailProvider()
);
DIP in React with Context API
// Dependency injection via React Context
interface ApiClient {
get<T>(url: string): Promise<T>;
post<T>(url: string, data: any): Promise<T>;
}
interface Logger {
log(message: string): void;
error(message: string, error: Error): void;
}
// Create contexts
const ApiClientContext = React.createContext<ApiClient | null>(null);
const LoggerContext = React.createContext<Logger | null>(null);
// Custom hooks for dependencies
function useApiClient() {
const client = useContext(ApiClientContext);
if (!client) throw new Error('ApiClient not provided');
return client;
}
function useLogger() {
const logger = useContext(LoggerContext);
if (!logger) throw new Error('Logger not provided');
return logger;
}
// Component depends on abstractions
function UserList() {
const apiClient = useApiClient();
const logger = useLogger();
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
apiClient.get<User[]>('/users')
.then(setUsers)
.catch(error => logger.error('Failed to fetch users', error));
}, [apiClient, logger]);
return (
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
// Provide dependencies at root
function App() {
const apiClient = useMemo(() => new FetchApiClient(), []);
const logger = useMemo(() => new ConsoleLogger(), []);
return (
<ApiClientContext.Provider value={apiClient}>
<LoggerContext.Provider value={logger}>
<UserList />
</LoggerContext.Provider>
</ApiClientContext.Provider>
);
}
// Testing: Provide mocks
function TestApp() {
const mockApiClient = useMemo(() => new MockApiClient(), []);
const mockLogger = useMemo(() => new MockLogger(), []);
return (
<ApiClientContext.Provider value={mockApiClient}>
<LoggerContext.Provider value={mockLogger}>
<UserList />
</LoggerContext.Provider>
</ApiClientContext.Provider>
);
}
When SOLID Becomes Overkill
Not all code benefits from strict SOLID adherence. Premature abstraction creates unnecessary complexity.
Overkill Example: Simple String Formatting
// BAD: Overkill for simple utility
interface StringFormatter {
format(input: string): string;
}
class UpperCaseFormatter implements StringFormatter {
format(input: string): string {
return input.toUpperCase();
}
}
class FormatterFactory {
static create(type: string): StringFormatter {
switch (type) {
case 'uppercase':
return new UpperCaseFormatter();
default:
throw new Error('Unknown formatter');
}
}
}
// Simple function is sufficient
const toUpperCase = (str: string) => str.toUpperCase();
When to Apply SOLID Strictly
- Large codebases with multiple teams
- Libraries and frameworks with public APIs
- Long-lived enterprise applications
- Complex business logic requiring high testability
- Systems requiring flexibility in implementation swapping
When to Relax SOLID
- Prototypes and MVPs where speed matters more than architecture
- Small utility functions where abstraction overhead exceeds benefits
- Stable CRUD applications with unlikely requirement changes
- One-off scripts and tools with short lifespans
- When patterns haven’t emerged - wait for duplication before abstracting
TypeScript’s Role
TypeScript significantly enhances SOLID in JavaScript:
// Explicit interfaces for ISP and DIP
interface PaymentProcessor {
process(amount: number): Promise<void>;
}
class CreditCardProcessor implements PaymentProcessor {
async process(amount: number): Promise<void> {
// Must implement or get compile error
}
}
// Type checking prevents LSP violations
interface Bird {
fly(): void;
}
class Penguin implements Bird {
fly() {
throw new Error('Cannot fly'); // TypeScript catches this
}
}
// Better: Different interfaces
interface FlyingBird {
fly(): void;
}
interface SwimmingBird {
swim(): void;
}
class Sparrow implements FlyingBird {
fly() { console.log('Flying'); }
}
class Penguin implements SwimmingBird {
swim() { console.log('Swimming'); }
}
// Generics for type-safe abstraction (DIP and LSP)
interface Repository<T> {
findById(id: string): Promise<T | null>;
save(entity: T): Promise<T>;
delete(id: string): Promise<void>;
}
class UserRepository implements Repository<User> {
async findById(id: string): Promise<User | null> {
return null;
}
async save(user: User): Promise<User> {
return user;
}
async delete(id: string): Promise<void> {}
}
// Union types as alternative to class hierarchies
type Shape =
| { type: 'circle'; radius: number }
| { type: 'rectangle'; width: number; height: number }
| { type: 'triangle'; base: number; height: number };
function calculateArea(shape: Shape): number {
switch (shape.type) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
}
}
Key Takeaways
SOLID principles remain valuable in JavaScript but require adaptation:
- SRP translates directly to functions, modules, components, and hooks
- OCP works through composition and higher-order functions, not deep inheritance
- LSP matters more with TypeScript - dynamic typing allows duck typing
- ISP naturally supported by JavaScript’s flexibility - TypeScript makes it explicit
- DIP essential for testability - constructor injection and Context API work well
React hooks align naturally with SOLID:
- Custom hooks enforce SRP
- Hooks compose without modification (OCP)
- Hook interfaces should be minimal (ISP)
- Dependencies injected via parameters or Context (DIP)
Balance pragmatism with principles:
- Small utilities don’t need abstraction layers
- Wait for patterns to emerge before abstracting
- Prototypes can skip architecture for speed
- Large codebases benefit from strict SOLID adherence
TypeScript enhances SOLID:
- Interfaces make ISP and DIP explicit
- Type checking prevents LSP violations
- Generics enable type-safe abstraction
- Utility types help create focused interfaces
The goal isn’t dogmatic adherence to SOLID principles, but understanding when they improve code quality versus when they introduce unnecessary complexity. In JavaScript’s dynamic, functional environment, these principles provide valuable guidance when adapted thoughtfully to the language’s strengths.
Related posts
Exploring how Decorator, Adapter, Facade, Composite, and Proxy patterns evolved in React and TypeScript. Learn when HOCs give way to hooks, how adapters isolate third-party APIs, and when facades simplify complexity.
Explore the architectural evolution from rule-based chatbots to autonomous AI agents. Learn ReAct, Plan-and-Execute, and multi-agent patterns with TypeScript implementations and practical migration strategies.
Exploring how Singleton, Factory, Builder, and Prototype patterns have evolved in TypeScript. Learn when ES modules replace singletons, when factory functions beat classes, and how TypeScript's type system changes the game.
Exploring modern patterns that emerged from JavaScript and TypeScript ecosystems - hooks, compound components, render props, and repository patterns that solve problems the GoF never encountered.
Cargo-culting Claude Code configurations leads to context window bloat, degraded tool selection, and mismatched workflows. A data-backed guide to intentional AI tool configuration with token budget math and progressive enhancement.