SOLID Principles in JavaScript: Practical Guide with TypeScript and React
How SOLID principles apply to modern JavaScript: practical examples with TypeScript, React hooks, and functional patterns, plus when they're overkill.
SOLID principles were formulated for object-oriented programming, but modern JavaScript development looks different: functional patterns, React hooks, dynamic typing. They still hold, with one adaptation. Anchor each principle to a composition boundary (a function, a module, a hook, a component’s prop list) instead of a class hierarchy, and all five survive the move.
Those five are Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. What changes in JavaScript is the unit they constrain, not the guarantee they buy. Most of the pain people blame on SOLID comes from importing its Java-shaped ceremony (factories, deep hierarchies, stacked interfaces) into a language that already gets those guarantees from closures and structural types.
What JavaScript Changes
The principles were written 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
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
A common violation packs three jobs into one component:
// 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
The violation shows up as a dispatcher that grows a branch per payment method:
// 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 only need to match the shape; JavaScript requires no explicit interface declaration, and TypeScript adds compile-time safety on top of it.
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 moves several of these checks to compile time, and it marks one boundary where it cannot help:
// 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 does not catch this LSP violation: the signature matches, the behavior does not
interface Bird {
fly(): void;
}
class Penguin implements Bird {
fly() {
throw new Error('Cannot fly'); // Compiles fine, fails at runtime
}
}
// Better: segregate the interfaces so the compiler can rule out the bad call
interface FlyingBird {
fly(): void;
}
interface SwimmingBird {
swim(): void;
}
class Sparrow implements FlyingBird {
fly() { console.log('Flying'); }
}
class SwimmingPenguin 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;
}
}
Where to Draw the Line
The default holds when a principle lands on a boundary the code already has. A custom hook that owns one concern, a strategy object added without touching the dispatcher, a prop list narrowed to the fields a component actually reads, a constructor that receives its database instead of building one: those four moves carry most of SOLID’s value into a TypeScript codebase, and not one of them needs a class hierarchy.
Override the default when the abstraction outlives its justification. An interface with a single implementation, a factory with a single branch, or a hook that only forwards its arguments all pay setup cost for flexibility nobody has needed yet. Wait for the second implementation before abstracting. In a prototype or a script with a known end date, skipping the layer is the correct call, and the result stays readable because there is less of it to read.
References
- Clean Code Episode 8: SOLID Principles - Robert C. Martin - Uncle Bob’s foundational lectures on SOLID in object-oriented design
- Refactoring.Guru: Design Patterns - Practical catalog of patterns that complement SOLID principles, with TypeScript examples
- MDN: JavaScript Classes - Reference for class syntax underlying OOP and SOLID in JavaScript
- typescript-eslint: Getting Started - Tooling to enforce SOLID-aligned TypeScript rules at compile time
- MDN: Using Classes - JavaScript Guide - Detailed guide to JavaScript class mechanics and prototype inheritance
Related posts
How Decorator, Adapter, Facade, Composite, and Proxy patterns evolved in React and TypeScript: when HOCs give way to hooks and how adapters isolate third-party APIs.
The architectural evolution from rule-based chatbots to autonomous AI agents, covering ReAct, Plan-and-Execute, and multi-agent patterns in TypeScript.
How Singleton, Factory, Builder, and Prototype patterns evolved in TypeScript: when ES modules replace singletons and when factory functions beat classes.
Modern patterns from the JavaScript and TypeScript ecosystems - hooks, compound components, render props, and repositories that solve problems the GoF never faced.
Nub and Vite+ are both 2026 oxc-powered Rust toolchains that look like rivals but are not. A clear rule for which binary belongs in which repo.