Skip to content

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:

typescript
// BAD: Violates SRP: Component handles data fetching, state, and renderingfunction 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:

typescript
// Follows SRP: Data fetching responsibilityfunction 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 responsibilityfunction 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 responsibilityfunction 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

typescript
// BAD: Module with unrelated concerns// utils/user.jsexport 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:

typescript
// Cohesive modules// utils/validation.jsexport function validateEmail(email) { /* ... */ }export function validatePassword(password) { /* ... */ }
// utils/formatting.jsexport function formatCurrency(amount) { /* ... */ }export function formatDate(date) { /* ... */ }
// services/weather.jsexport 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:

typescript
// BAD: Violates OCP: Must modify function for each new payment methodfunction 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:

typescript
// Follows OCP: Extend without modificationtype 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 processPaymentawait processPayment(createCreditCardProcessor(cardDetails), 100);await processPayment(createPayPalProcessor('[email protected]'), 100);
// Add crypto without modifying existing codeconst 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

typescript
// OCP through compositiontype Middleware = (data: any) => any;
const compose = (...fns: Middleware[]) => (data: any) =>  fns.reduce((result, fn) => fn(result), data);
// Base transformationsconst toLowerCase = (str: string) => str.toLowerCase();const trim = (str: string) => str.trim();const removeSpaces = (str: string) => str.replace(/\s/g, '');
// Extend by composing - no modification neededconst normalizeEmail = compose(trim, toLowerCase);const normalizeUsername = compose(trim, toLowerCase, removeSpaces);
// Add new transformationconst 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:

typescript
// BAD: Violates LSP: Square breaks Rectangle's contractclass 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 Squarefunction 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:

typescript
// Follows LSP: Use composition insteadinterface 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 issuesfunction printArea(shape: Shape) {  console.log(`Area: ${shape.getArea()}`);}
printArea(new Rectangle(10, 5)); // WorksprintArea(new Square(5)); // Works

LSP in React Components

typescript
// BAD: Violates LSP: Enhanced button requires additional propinterface 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 enhancementinterface 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 usedfunction 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

typescript
// BAD: Violates ISP: Fat interface forces unused method implementationsinterface 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:

typescript
// Follows ISP: Segregated interfacesinterface Workable {  work(): void;}
interface Eatable {  eat(): void;}
interface Sleepable {  sleep(): void;}
interface Maintainable {  getMaintenance(): void;}
// Implement only needed interfacesclass 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 needfunction makeWork(worker: Workable) {  worker.work();}
function feedWorker(worker: Eatable) {  worker.eat();}
const human = new HumanWorker();const robot = new RobotWorker();
makeWork(human); // WorksmakeWork(robot); // WorksfeedWorker(human); // Works// feedWorker(robot); // BAD: Compile error - Robot doesn't implement Eatable

ISP in React Components

typescript
// BAD: Violates ISP: Component depends on entire User typeinterface 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 usesinterface UserGreetingProps {  name: string;}
function UserGreeting({ name }: UserGreetingProps) {  return <h1>Hello, {name}!</h1>;}
// Usage: Pass only needed datafunction App() {  const user: User = fetchUser();  return <UserGreeting name={user.name} />;}

TypeScript Utility Types for ISP

typescript
// Large interfaceinterface User {  id: string;  email: string;  password: string;  firstName: string;  lastName: string;  address: Address;  phoneNumber: string;  createdAt: Date;}
// Create focused types using utility typestype 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 propertiesfunction 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

typescript
// BAD: Violates DIP: UserService directly depends on concrete implementationsclass 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:

typescript
// Follows DIP: Depend on abstractionsinterface Database {  connect(): Promise<void>;  query<T>(sql: string, params?: any[]): Promise<T>;}
interface EmailProvider {  send(to: string, subject: string, body: string): Promise<void>;}
// Concrete implementationsclass 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 abstractionsclass 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 implementationsconst userService = new UserService(  new MySQLDatabase(),  new GmailEmailProvider());
// Testing: Inject mocksconst testUserService = new UserService(  new MockDatabase(),  new MockEmailProvider());
// Easy to switch implementationsconst productionUserService = new UserService(  new PostgreSQLDatabase(),  new SendGridEmailProvider());

DIP in React with Context API

typescript
// Dependency injection via React Contextinterface 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 contextsconst ApiClientContext = React.createContext<ApiClient | null>(null);const LoggerContext = React.createContext<Logger | null>(null);
// Custom hooks for dependenciesfunction 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 abstractionsfunction 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 rootfunction 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 mocksfunction 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

typescript
// BAD: Overkill for simple utilityinterface 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 sufficientconst 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:

typescript
// Explicit interfaces for ISP and DIPinterface 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 violationsinterface Bird {  fly(): void;}
class Penguin implements Bird {  fly() {    throw new Error('Cannot fly'); // TypeScript catches this  }}
// Better: Different interfacesinterface 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 hierarchiestype 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:

  1. SRP translates directly to functions, modules, components, and hooks
  2. OCP works through composition and higher-order functions, not deep inheritance
  3. LSP matters more with TypeScript - dynamic typing allows duck typing
  4. ISP naturally supported by JavaScript's flexibility - TypeScript makes it explicit
  5. 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.

References

Related Posts