DEV Community

Cover image for Applying SOLID Principles in JavaScript and TypeScript Framework
Wafa Bergaoui
Wafa Bergaoui

Posted on

Applying SOLID Principles in JavaScript and TypeScript Framework

Introduction

The SOLID principles form the foundation of clean, scalable, and maintainable software development. Though these principles originated in Object-Oriented Programming (OOP), they can be effectively applied in JavaScript (JS) and TypeScript (TS) frameworks like React and Angular. This article explains each principle with real-life examples in both JS and TS.


1. Single Responsibility Principle (SRP)

Principle: A class or module should have only one reason to change. It should be responsible for a single piece of functionality.

  • Example in JavaScript (React):

In React, we often see components responsible for too many things—such as managing both UI and business logic.

Anti-pattern:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUserData();
  }, [userId]);

  async function fetchUserData() {
    const response = await fetch(`/api/users/${userId}`);
    const data = await response.json();
    setUser(data);
  }

  return <div>{user?.name}</div>;
}

Enter fullscreen mode Exit fullscreen mode

Here, the UserProfile component violates SRP because it handles both UI rendering and data fetching.

Refactor:

// Custom hook for fetching user data
function useUserData(userId) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    async function fetchUserData() {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      setUser(data);
    }
    fetchUserData();
  }, [userId]);

  return user;
}

// UI Component
function UserProfile({ userId }) {
  const user = useUserData(userId); // Moved data fetching logic to a hook

  return <div>{user?.name}</div>;
}

Enter fullscreen mode Exit fullscreen mode

By using a custom hook (useUserData), we separate the data-fetching logic from the UI, keeping each part responsible for a single task.

  • Example in TypeScript (Angular):

In Angular, services and components can become cluttered with multiple responsibilities.

Anti-pattern:

@Injectable()
export class UserService {
  constructor(private http: HttpClient) {}

  getUser(userId: string) {
    return this.http.get(`/api/users/${userId}`);
  }

  updateUserProfile(userId: string, data: any) {
    // Updating the profile and handling notifications
    return this.http.put(`/api/users/${userId}`, data).subscribe(() => {
      console.log('User updated');
      alert('Profile updated successfully');
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

This UserService has multiple responsibilities: fetching, updating, and handling notifications.

Refactor:


@Injectable()
export class UserService {
  constructor(private http: HttpClient) {}

  getUser(userId: string) {
    return this.http.get(`/api/users/${userId}`);
  }

  updateUserProfile(userId: string, data: any) {
    return this.http.put(`/api/users/${userId}`, data);
  }
}

// Separate notification service
@Injectable()
export class NotificationService {
  notify(message: string) {
    alert(message);
  }
}

Enter fullscreen mode Exit fullscreen mode

By splitting the notification handling into a separate service (NotificationService), we ensure that each class has a single responsibility.


2. Open/Closed Principle (OCP)

Principle: Software entities should be open for extension but closed for modification. This means that you should be able to extend the behavior of a module without altering its source code.

  • Example in JavaScript (React):

You might have a form validation function that works well but could require additional validation logic in the future.

Anti-pattern:

function validate(input) {
  if (input.length < 5) {
    return 'Input is too short';
  }
  if (!input.includes('@')) {
    return 'Invalid email';
  }
  return 'Valid input';
}
Enter fullscreen mode Exit fullscreen mode

Whenever you need a new validation rule, you'd have to modify this function, violating OCP.

Refactor:

function validate(input, rules) {
  return rules.map(rule => rule(input)).find(result => result !== 'Valid') || 'Valid input';
}

const lengthRule = input => input.length >= 5 ? 'Valid' : 'Input is too short';
const emailRule = input => input.includes('@') ? 'Valid' : 'Invalid email';

validate('test@domain.com', [lengthRule, emailRule]);
Enter fullscreen mode Exit fullscreen mode

Now, we can extend validation rules without modifying the original validate function, adhering to OCP.

  • Example in TypeScript (Angular):

In Angular, services and components should be designed to allow new features to be added without modifying the core logic.

Anti-pattern:

export class NotificationService {
  send(type: 'email' | 'sms', message: string) {
    if (type === 'email') {
      // Send email
    } else if (type === 'sms') {
      // Send SMS
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This service violates OCP since you'd need to modify the send method every time you add a new notification type (e.g., push notifications).

Refactor:

interface Notification {
  send(message: string): void;
}

@Injectable()
export class EmailNotification implements Notification {
  send(message: string) {
    // Send email logic
  }
}

@Injectable()
export class SMSNotification implements Notification {
  send(message: string) {
    // Send SMS logic
  }
}

@Injectable()
export class NotificationService {
  constructor(private notifications: Notification[]) {}

  notify(message: string) {
    this.notifications.forEach(n => n.send(message));
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, adding new notification types only requires creating new classes without changing the NotificationService itself.


3. Liskov Substitution Principle (LSP)

Principle: Subtypes must be substitutable for their base types. Derived classes or components should be able to replace base classes without affecting the correctness of the program.

  • Example in JavaScript (React):

When using higher-order components (HOCs) or rendering different components conditionally, LSP helps ensure that all components behave predictably.

Anti-pattern:

function Button({ onClick }) {
  return <button onClick={onClick}>Click me</button>;
}

function LinkButton({ href }) {
  return <a href={href}>Click me</a>;
}

<Button onClick={() => {}} />;
<LinkButton href="/home" />;
Enter fullscreen mode Exit fullscreen mode

Here, Button and LinkButton are inconsistent. One uses onClick, and the other uses href, making substitution difficult.

Refactor:

function Clickable({ children, onClick }) {
  return <div onClick={onClick}>{children}</div>;
}

function Button({ onClick }) {
  return <Clickable onClick={onClick}>
    <button>Click me</button>
  </Clickable>;
}

function LinkButton({ href }) {
  return <Clickable onClick={() => window.location.href = href}>
    <a href={href}>Click me</a>
  </Clickable>;
}
Enter fullscreen mode Exit fullscreen mode

Now, both Button and LinkButton behave similarly, adhering to LSP.

  • Example in TypeScript (Angular):

Anti-pattern:

class Rectangle {
  constructor(protected width: number, protected height: number) {}

  area() {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  constructor(size: number) {
    super(size, size);
  }

  setWidth(width: number) {
    this.width = width;
    this.height = width; // Breaks LSP
  }
}
Enter fullscreen mode Exit fullscreen mode

Modifying setWidth in Square violates LSP because Square behaves differently from Rectangle.

Refactor:

class Shape {
  area(): number {
    throw new Error('Method not implemented');
  }
}

class Rectangle extends Shape {
  constructor(private width: number, private height: number) {
    super();
  }

  area() {
    return this.width * this.height;
  }
}

class Square extends Shape {
  constructor(private size: number) {
    super();
  }

  area() {
    return this.size * this.size;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, Square and Rectangle can be substituted without violating LSP.


4. Interface Segregation Principle (ISP):

Principle: Clients should not be forced to depend on interfaces they do not use.

  • Example in JavaScript (React):

React components sometimes receive unnecessary props, leading to tightly coupled and bulky code.

Anti-pattern:

function MultiPurposeComponent({ user, posts, comments }) {
  return (
    <div>
      <UserProfile user={user} />
      <UserPosts posts={posts} />
      <UserComments comments={comments} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here, the component depends on multiple props, even though it might not always use them.

Refactor:

function UserProfileComponent({ user }) {
  return <UserProfile user={user} />;
}

function UserPostsComponent({ posts }) {
  return <UserPosts posts={posts} />;
}

function UserCommentsComponent({ comments }) {
  return <UserComments comments={comments} />;
}
Enter fullscreen mode Exit fullscreen mode

By splitting the component into smaller ones, each only depends on the data it actually uses.

  • Example in TypeScript (Angular):

Anti-pattern:

interface Worker {
  work(): void;
  eat(): void;
}

class HumanWorker implements Worker {
  work() {
    console.log('Working');
  }
  eat() {
    console.log('Eating');
  }
}

class RobotWorker implements Worker {
  work() {
    console.log('Working');
  }
  eat() {
    throw new Error('Robots do not eat'); // Violates ISP
  }
}
Enter fullscreen mode Exit fullscreen mode

Here, RobotWorker is forced to implement an irrelevant eat method.

Refactor:

interface Worker {
  work(): void;
}

interface Eater {
  eat(): void;
}

class HumanWorker implements Worker, Eater {
  work() {
    console.log('Working');
  }

  eat() {
    console.log('Eating');
  }
}

class RobotWorker implements Worker {
  work() {
    console.log('Working');
  }
}
Enter fullscreen mode Exit fullscreen mode

By separating Worker and Eater interfaces, we ensure that clients only depend on what they need.


5. Dependency Inversion Principle (DIP):

Principle: High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces).

  • Example in JavaScript (React):

Anti-pattern:

function fetchUser(userId) {
  return fetch(`/api/users/${userId}`).then(res => res.json());
}

function UserComponent({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  return <div>{user?.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Here, UserComponent is tightly coupled with the fetchUser function.

Refactor:

function UserComponent({ userId, fetchUserData }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUserData(userId).then(setUser);
  }, [userId, fetchUserData]);

  return <div>{user?.name}</div>;
}

// Usage
<UserComponent userId={1} fetchUserData={fetchUser} />;
Enter fullscreen mode Exit fullscreen mode

By injecting fetchUserData into the component, we can easily swap out the implementation for testing or different use cases.

  • Example in TypeScript (Angular):

Anti-pattern:

@Injectable()
export class UserService {
  constructor(private http: HttpClient) {}

  getUser(userId: string) {
    return this.http.get(`/api/users/${userId}`);
  }
}

@Injectable()
export class UserComponent {
  constructor(private userService: UserService) {}

  loadUser(userId: string) {
    this.userService.getUser(userId).subscribe(user => console.log(user));
  }
}
Enter fullscreen mode Exit fullscreen mode

UserComponent is tightly coupled with UserService, making it hard to swap out UserService.

Refactor:

interface UserService {
  getUser(userId: string): Observable<User>;
}

@Injectable()
export class ApiUserService implements UserService {
  constructor(private http: HttpClient) {}

  getUser(userId: string) {
    return this.http.get<User>(`/api/users/${userId}`);
  }
}

@Injectable()
export class UserComponent {
  constructor(private userService: UserService) {}

  loadUser(userId: string) {
    this.userService.getUser(userId).subscribe(user => console.log(user));
  }
}
Enter fullscreen mode Exit fullscreen mode

By depending on an interface (UserService), UserComponent is now decoupled from the concrete implementation of ApiUserService.


Next Steps

Whether you're working on the front end with frameworks like React or Angular, or on the back end with Node.js, the SOLID principles serve as a guide to ensure that your software architecture remains solid.

To fully integrate these principles into your projects:

  • Practice regularly: Refactor existing codebases to apply SOLID principles and review code for adherence.
  • Collaborate with your team: Encourage best practices through code reviews and discussions around clean architecture.
  • Stay curious: SOLID principles are just the beginning. Explore other architectural patterns like MVC, MVVM, or CQRS that build on these fundamentals to further improve your designs.

Conclusion

The SOLID principles are highly effective for ensuring that your code is clean, maintainable, and scalable, even in JavaScript and TypeScript frameworks like React and Angular. Applying these principles enables developers to write flexible and reusable code that’s easy to extend and refactor as requirements evolve. By following SOLID, you can make your codebase robust and ready for future growth.

Top comments (10)

Collapse
 
oculus42 profile image
Samuel Rouse

This is an interesting take on applying SOLID principles to non-OOP programming. Thanks for creating this!

I do have some disagreement with the examples, though.

LSP

While the idea of Liskov Substitution Principle is reasonable, the example provided I think does more to confuse the topic. While a button and a link are both clickable, they should not share a base class. To provide an OOP equivalent, car, cat, and planet could all support the move method, but I would not expect a common class between the three.

We've misaligned "click" as the shared capability, mostly because it's one of the few common interactions. The link could be more accurately described with navigate and the button, depending on type, could be "submit" or "action".

The idea of a LinkButton is incorrectly represented, mostly because we introduced action buttons that appear to be links in various design schemes, from Bootstrap's ben-tertiary to Microsoft's Metro UI...whole vibe. LinkButton should still be a button, with perhaps different styling.

ISP

The Interface Segregation Principle React example is wholly confusing to me. The MultiPurposeComponent is accumulating the presentation of three already distinct components that show clear segregation of the interface. . The refactor does not provide equivalent functionality at all, simply wrapping the original components, but not creating any output. If the props come from one source, they "anti-pattern" will need to be recreated almost exactly as-is.

If the original included the markup for each of the nested components, the example would have made sense, but it does not, here.

OCP (Nitpicking)

As a small note, in the OCP example, I would recommend reversing the order of arguments to validate. This is more a functional programming consideration, but if we want validate to operate as it did before, we need to pass the rules first, then accept the value. This could be done with Higher-order-functions (HOFs) or with currying...

// HOF to accept rules, then value
const validate = (rules)  => (value) =>  rules
  .map(rule => rule(input))
  .find(result => result !== 'Valid') || 'Valid input';

// Use currying so we can pass rules first, either separately or together 
const validate = curry((rules, value) => /* ... */)`
Enter fullscreen mode Exit fullscreen mode
Collapse
 
wafa_bergaoui profile image
Wafa Bergaoui

Thanks for your thoughtful feedback! I really appreciate the time you took to dig into the examples and share your perspective.

Regarding the Liskov Substitution Principle (LSP) example, you raise a valid point. The intention was to illustrate shared behavior, but I can see how the "click" interaction may have oversimplified the concept, particularly when applied to elements with different purposes like Link and Button. Your suggestion of focusing on distinct actions such as navigate and submit definitely clarifies this, and I’ll be refining this example.

For the Interface Segregation Principle (ISP), the aim was to highlight separation of concerns by breaking functionality into smaller, distinct components, but I understand how the current example didn’t reflect that clearly. I’ll revisit this to ensure the refactor reflects a clearer segregation of functionality.

As for the Open/Closed Principle (OCP), I appreciate your recommendation on using higher-order functions or currying to improve flexibility. I wanted to keep it simple, but your suggestion adds an additional layer of sophistication, and I’ll incorporate that into future revisions.

I value feedback like yours—it helps me continue to grow and improve the clarity of my explanations. Thanks again for taking the time to engage with the article!

Collapse
 
sugrivlodhi profile image
Sugriv Lodhi • Edited

I enjoy refactoring the codebase and keeping it as clean as possible. During refactoring and code reviews, I provided feedback to my team, but I didn't consciously apply the SOLID principles at the time. Most of the SOLID principles were already part of my daily practice, but I wasn’t aware of them. However, after reading this article, I am now fully confident that when I review or write code, I will keep the SOLID principles in mind, maintain the codebase properly, and provide constructive feedback. Thank you, Wafa Bergaou

Collapse
 
wafa_bergaoui profile image
Wafa Bergaoui

Thanks for your comment! I’m really happy to hear that the article helped make the SOLID principles clearer for you. It sounds like you’ve been applying them naturally, which is awesome! Now that you’re more aware of them, I’m sure your code reviews and refactoring will be even stronger. Feel free to reach out anytime, and thanks again for reading!

Collapse
 
akshatsoni26 profile image
Akshat Soni

Thanks for sharing.

Collapse
 
wafa_bergaoui profile image
Wafa Bergaoui

You're welcome! Glad you found it helpful! 😊

Collapse
 
efpage profile image
Eckehard

The SOLID principles have been introduced by Robert C. Martin (Uncle Bob) and reflect his experience of what he thinks could help people to use OOP for web development,

They do not necessarily apply to OOP in general, as many OOP projects do not follow this principles. Most core API´s like the Windows GDI implement large and deeply nestend hierarchies which surely violates the SOLID principles. But they are build with optimizing compilers that do tree shaking by default, so things are much different here.

Collapse
 
syedmuhammadaliraza profile image
Syed Muhammad Ali Raza

👍

Collapse
 
lumariaap15 profile image
lumariaap15

I wonder if implementing a global state would be a better solution for the Dependency Inversion Principle (DIP) problem?

Collapse
 
aaronre16397861 profile image
Aaron Reese

I'm no expert on SOLID but I think global state is misunderstanding the problem. You would still need the user slice of state. Rather than have the function get the slice, solid says you have a separate function that gets the slice and pass the function in as an argument. This means that if you have to change the shape of the user slice you only have to change the getter function and if you want a unit test you can use a different function that generates a static user, especially useful when you need to test validation rules as your real data source may not have real examples.