SOLID Principles using Typescript

Yatin M.
Technology18 mins read
solid-2tiny.webp

Typescript is like the object-oriented version of Javascript. This article discusses the best practices of object-oriented software design using Typescript.

In software design over many years, commonly occurring problems were identified. Re-usable solutions to these problems are called Design Patterns. Before diving into design patterns, we need to revisit a few basic principles of software development. Let’s start with SOLID.

SOLID is an acronym for 5 principles.

  1. Single Responsibility Principle
  2. Open/Closed Principle
  3. The Liskov Substitution Principle
  4. Interface Segregation Principle
  5. Dependency Inversion Principle

We will discuss each of the above in detail with practical examples using Typescript. To run these examples using the terminal, simply run the following commands —

tsc --target es5 <filename.ts>

node <filename.js>

Single Responsibility Principle

Description

This principle states that every method/class should handle a single responsibility. This is important because it results in better readability of code and separation of concerns.

The Challenge

Let’s jump directly into a practical example. Suppose in a particular API, we wish to fetch posts, clean up some data, and then send back a response. Here’s some fairly easy to use code that should serve our purposes:

import fetch from "node-fetch";

const getPosts = async (userId: number) => {
  try {
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/users/${userId}/posts`
    );
    const posts = await response.json();
    // Do some cleanup; remove UserID from post since it's not really needed
    const cleanedPosts = posts.map((post) => {
      delete post["userId"];
      return post;
    });
    return cleanedPosts;
  } catch (e) {
    // Log error in some kind of Error Logging Service, here we just do console log
    console.log(e);
    // Send a meaningful but non-technical error message back to the end-user
    throw Error("Error while fetching Posts!");
  }
};

const main = async () => {
  const result = await getPosts(1);
  console.log(result);
};

main();

Where this fails

This approach works but has a few issues that become pretty substantial when working with larger codebases.

  1. The function handles too many things — fetching data, error handling, and even cleaning up of posts.
  2. It is difficult to re-use — again the tight-coupling is an issue.

Solution

The above code can be made cleaner and simpler by enforcing the Single Responsibility Principle. This can be done in two steps:

  1. Taking the error handling code out of the main function — the error handling part can be generic and common to every other function.
  2. Extracting the cleanupPosts to a new function since isn’t really a responsibility for fetchPosts.
import fetch from "node-fetch";

const fetchPosts = async (userId: number) => {
  try {
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/users/${userId}/posts`
    );
    return await response.json();
  } catch (e) {
    handleError(e, "Error while fetching Posts!");
  }
};

const handleError = (e, message) => {
  // Log error in some kind of Error Logging Service, here we just do console log
  console.log(e);
  // Send a generic Error message back to the user
  throw Error(message);
};

const cleanupPosts = (posts) => {
  // Do some cleanup; remove UserID from post since it's not really needed
  return posts.map((post) => {
    delete post["userId"];
    return post;
  });
};

const main = async () => {
  const posts = await fetchPosts(1);
  console.log(cleanupPosts(posts));
};

Summary

The Single Responsibility Principle is the easiest to understand, digest and follow of all the SOLID principles. In case you need a trigger to keep up with it, just keep in mind that a class/module should have only 1 reason to change.

Open/Closed Principle

Description

The core meaning of the Open/Closed principle is made clear by the statement: *open to extension, closed for modification. *The idea is that a class, once implemented, should be closed for any further modification. If any more functionality is needed, it can be added later using extension features such as inheritance. This is primarily done so as to not break existing code as well as unit tests. It also results in a modular code.

The Challenge

Suppose there is a NotificationService that helps us send out an email to the end-user. The gist is self-explanatory. There are 2 classes — EmailService and NotificationService.NotificationService calls thesendEmail onEmailService.

class EmailService {
  public sendEmail(email: string, message: string): void {
    console.log(`Email Sent: ${message} to ${email}`);
  }
}

class NotificationService {
  private _emailService: EmailService;
  constructor() {
    this._emailService = new EmailService();
  }
  public sendNotification(email: string, message: string) {
    this._emailService.sendEmail(email, message);
  }
}

const main = () => {
  const notificationService = new NotificationService();
  notificationService.sendNotification(
    "hello@example.com",
    "Generic Notification"
  );
};

main();

Now, extending this example — let's add a requirement to create a notification when an order is completed, sending both an Email and an SMS to the end-user. One way to solve this would be to create a new SMSService which is also initialized in the NotificationService class.

class EmailService {
  public sendEmail(email: string, message: string): void {
    console.log(`Email Sent: ${message}`);
  }
}

class SMSService {
  public sendSms(phone: number, message: string): void {
    console.log(`Message ${message} sent to ${phone}`);
  }
}

class NotificationService {
  private _emailService: EmailService;
  private _smsService: SMSService;

  constructor() {
    this._emailService = new EmailService();
    this._smsService = new SMSService();
  }
  public sendNotification(
    email: string,
    message: string,
    phone: number,
    smsMessage: string
  ) {
    this._emailService.sendEmail(email, message);
    if (phone && smsMessage) {
      this._smsService.sendSms(phone, smsMessage);
    }
  }
}

const main = () => {
  const orderNotificationService = new NotificationService();
  orderNotificationService.sendNotification(
    "hello@example.com",
    "Generic Notification",
    9876543210,
    "SMS Notification"
  );
};

main();

Where this Fails

The above solution works well, looks clean and produces the desired functional outcome. But the tests fail, and all instances of these services will need to be modified in the code. Additionally, what if the code is closed to modification already — for instance, what if the base classes are part of a library? This is where sticking to the Open/Closed principle aids us.

Solution

Let’s try to fix the above and add the SMS Service without modifying the baseNotificationService class.

class EmailService {
  public sendEmail(email: string, message: string): void {
    console.log(`Email Sent: ${message}`);
  }
}

class SMSService {
  public sendSms(phone: number, message: string): void {
    console.log(`Message ${message} sent to ${phone}`);
  }
}

class NotificationService {
  private _emailService: EmailService;
  constructor() {
    this._emailService = new EmailService();
  }
  public sendNotification(email: string, message: string) {
    this._emailService.sendEmail(email, message);
  }
}

class OrderNotificationService extends NotificationService {
  private _smsService: SMSService;
  constructor() {
    super();
    this._smsService = new SMSService();
  }

  public sendOrderNotification(
    email: string,
    emailMessage: string,
    phone?: number,
    smsMessage?: string
  ) {
    if (email && emailMessage) {
      this.sendNotification(email, emailMessage);
    }
    if (phone && smsMessage) {
      this._smsService.sendSms(phone, smsMessage);
    }
  }
}

const main = () => {
  const orderNotificationService = new OrderNotificationService();
  orderNotificationService.sendOrderNotification(
    "hello@example.com",
    "Order accepted",
    9876543210,
    "Order Accepted"
  );
};

main();

In the above solution, rather than modifying the NotificationService class, we instead create a separate OrderNotificationService class. This extends the generic NotificationService and instantiates theSMSService class. There are a number of pros for this approach:

  1. Previous code remains untouched
  2. No breaking test cases
  3. No referential changes to other parts of the code

Summary

Two key ideas for summarising the Open/Closed principle are as follows:

  1. A module will be considered open if it’s available for extension.
  2. A module will be considered closed if it’s available for use by other submodules.

This principle is most crucial for enterprise/large codebases. The impact is large because modifying a module might have unforeseen consequences in various submodule implementations.

Liskov Substitution Principle

Description

Imagine you have a class S which has subtypes S1, S2, S3. In object-oriented terms, assume a class Animal which is extended by subclasses like Dog , Cat etc. The Liksov Substitution Principle states that any object of type S (Animal in our case) can be substituted with any of its subclasses (S1, S2, S3). Since this type of substitution was first introduced by Barbara Liskov, it's known as the Liskov Substitution Principle.

Now if our Animal class has a walk method, it should work fine on instances of Dog and Cat both.

The Challenge

Suppose we’re building an Error handler for a particular web application and the requirements are to perform different types of actions based on the type of error. In this scenario, let’s just take 2 types of errors:

  1. Database Error
  2. Connection Error

Both of the above error classes extend an abstract class called CustomError

abstract class CustomError {
  error: Error;
  errorMessage: string;
  constructor(error: Error) {
    this.error = error;
  }
  abstract createErrorMessage(): void;
  abstract logError(): void;
}

Now, the ConnectionError class implements the CustomError class using a constructor and two abstract methods —createErrorMessage and logError.

class ConnectionError extends CustomError {
  constructor(error: Error) {
    super(error);
  }
  createErrorMessage(): void {
    this.errorMessage = `Connection error: ${this.error.message}`;
  }
  logError(): void {
    console.log(this.errorMessage);
  }
}

But the DatabaseError class is also implemented similarly, except forone requirement changewherein the database error being critical in nature also needs a createAlert method.

class DBError extends CustomError {
  constructor(error: Error) {
    super(error);
  }
  createErrorMessage(): void {
    this.errorMessage = `DB error: ${this.error.message}`;
  }
  logError(): void {
    console.log(this.errorMessage);
  }
  createAlert(): void {
    console.log("Alert Sent");
  }
}

Where This Fails

The above example clearly violates the Liskov Substitution principle. Using a subclass of DBError can be an issue when you try to use it in a common error handler function:

abstract class CustomError {
  error: Error;
  errorMessage: string;
  constructor(error: Error) {
    this.error = error;
  }
  abstract createErrorMessage(): void;
  abstract logError(): void;
}

class ConnectionError extends CustomError {
  constructor(error: Error) {
    super(error);
  }
  createErrorMessage(): void {
    this.errorMessage = `Connection error: ${this.error.message}`;
  }
  logError(): void {
    console.log(this.errorMessage);
  }
}

class DBError extends CustomError {
  constructor(error: Error) {
    super(error);
  }
  createErrorMessage(): void {
    this.errorMessage = `DB error: ${this.error.message}`;
  }
  logError(): void {
    console.log(this.errorMessage);
  }
  createAlert(): void {
    console.log("Alert Sent");
  }
}

const errorDecorator = (customError: CustomError) => {
  customError.createErrorMessage();
  customError.logError();
  if (customError instanceof DBError) {
    customError.createAlert();
  }
};

const main = () => {
  const dbError = new DBError(new Error("DB err1"));
  errorDecorator(dbError);
};

main();

In the above example, line 41 is a **code-smell — **because it requires knowing the instance type beforehand. Extend this case to future errors of APIError, GraphError and so on, and it results in a series of never-ending if/else cases. The problem arises because of the overgeneralization of use cases.

Solution

Predicting the future of these types of classes is where the problem exists. It is better to be defensive in such assumptions and go for a “has/a” **class type instead of an “is/a” **class type. Let’s take a look at an example to understand this better:

abstract class CustomError {
  error: Error;
  errorMessage: string;
  constructor(error: Error) {
    this.error = error;
  }
  abstract createErrorMessage(): void;
  abstract logError(): void;
}

class ConnectionError extends CustomError {
  constructor(error: Error) {
    super(error);
  }
  createErrorMessage(): void {
    this.errorMessage = `Connection error: ${this.error.message}`;
  }
  logError(): void {
    console.log(this.errorMessage);
  }
}

class AlertSystem {
  public sendAlert(message: string) {
    console.log("Alert sent");
  }
}

class DBError extends CustomError {
  constructor(error: Error) {
    super(error);
  }

  createErrorMessage(): void {
    this.errorMessage = `DB error: ${this.error.message}`;
  }

  logError(): void {
    console.log(this.errorMessage);
    const alert = new AlertSystem();
    alert.sendAlert(this.errorMessage);
  }
}

const errorDecorator = (customError: CustomError) => {
  customError.createErrorMessage();
  customError.logError();
};

const main = () => {
  const dbError = new DBError(new Error("DB err1"));
  errorDecorator(dbError);
};

main();

Considering our example of error handlers again: One approach can be to compose our logging method with an alerting mechanism. The AlertSystem is now used in composition and added to DBError’s logError instead. Another viable approach would have been to completely decouple the AlertSystem from both the errors. When compared to our previous examples we do not have any more if/else conditions on the type of class instance.

Summary

In my opinion, the Liskov Substitution principle should be treated as a guideline and not as a strict rule because in practice, this principle is the hardest to keep an eye on during development. This could be for a number of reasons- implementations might be in the different codebases, use of an external library in the codebase etc.

The key focus should be on 2 ideas-

  1. Do not work on generalizations prematurely for any domain.
  2. Try to maintain the superclass contract in the subclass.

Interface Segregation Principle

Description

The Interface Segregation Principle — or ISP for short — states that instead of a generalized interface for a class, it is better to use separate segregated interfaces with smaller functionalities. This is similar to ideas we’ve discussed so far around maintaining loose coupling, but for interfaces.

### The Challenge

Consider our previous example of PaymentProvider. This time, imagine that the PaymentProvider is an interface which is implemented by CreditCardPaymentProvider and WalletPaymentProvider.

interface PaymentProvider {
  validate: () => boolean;
  getPaymentCommission: () => number;
  processPayment: () => string;
  verifyPayment: () => boolean;
}

Let's implement the interface PaymentProvider for our CreditCartPaymentProvider class. The credit card provider does not provide an API to verify payment individually, but since we’re implementing PaymentProvider, we are required to implement the verifyPayment method, otherwise, the class implementation will throw an error.

class CreditCardPaymentProvider implements PaymentProvider {
  validate() {
    // Payment is validated
    console.log("Payment Card Validated");
    return true;
  }
  getPaymentCommission() {
    // Commission is returned
    return 10;
  }
  processPayment() {
    // Payment processed
    console.log("Payment Processed");
    return "Payment Fingerprint";
  }
  verifyPayment() {
    // No verify Payment API exist
    // Return false to just implement the Payment Provider
    return false;
  }
}

Now suppose the wallet providers do not have a validate API, to implement the PaymentProvider for WalletPaymentProvider. In this case, we must create a validate method — which does nothing as can be seen below:

class WalletPaymentProvider implements PaymentProvider {
  validate() {
    // No validate method exists
    // Just for sake of implementation return false
    return false;
  }
  getPaymentCommission() {
    // Commission is returned
    return 5;
  }
  processPayment() {
    // Payment processed
    console.log("Payment Processed");
    return "Payment Fingerprint";
  }
  verifyPayment() {
    // Payment verification does exist on Wallet Payment Provider
    console.log("Payment Verified");
    return false;
  }
}

Where This Fails

The above implementation works fine but seeing the fake implementations, we know this is a **code smell **that would quickly become an issue with a number of such fake implementations popping up throughout the code.

Solution

The above scenario can be fixed using the interface segregation principle. Firstly, we need to take a look at our interface rather than its implementation and see if it can be refactored to decouple various constituents of the PaymentProvider interface.

interface PaymentProvider {
  getPaymentCommission: () => number;
  processPayment: () => string;
}

interface PaymentValidator {
  validate: () => boolean;
}

interface PaymentVerifier {
  verifyPayment: () => boolean;
}

We now have three interfaces instead of one and each implementation can be decoupled further. Since the CreditCardPaymentProvider does not have any verifyPayment method, we can simply implement:

  1. PaymentProvider, and
  2. PaymentValidator
class CreditCardPaymentProvider implements PaymentProvider, PaymentValidator {
  validate() {
    // Payment is validated
    console.log("Payment Card Validated");
    return true;
  }
  getPaymentCommission() {
    // Commission is returned
    return 10;
  }
  processPayment() {
    // Payment processed
    console.log("Payment Processed");
    return "Payment Fingerprint";
  }
}

Similarly, the WalletPaymentProvider is also fixed with the class now implementing:

  1. PaymentProvider interface, and
  2. PaymentValidator interface
class WalletPaymentProvider implements PaymentProvider, PaymentVerifier {
  getPaymentCommission() {
    // Commission is returned
    return 5;
  }
  processPayment() {
    // Payment processed
    console.log("Payment Processed");
    return "Payment Fingerprint";
  }
  verifyPayment() {
    // Payment verification
    console.log("Payment Verified");
    return false;
  }
}

Finally, the cohesion issues and fake implementations are gone, and we’ve achieved the desired result using interface segregation.

Summary

Interface Segregation is one of my favourite design principles. In simple words, it proposes to split large interfaces into smaller ones with a specific purpose. This provides loose coupling, better management of code, and easier usability of code.

A key idea to grasp is of composition over inheritance. This might not be well supported by legacy designs but is substantially important for modern software architecture.

Dependency Inversion Principle

Description

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules, but rather on abstractions. Secondly, abstraction should not depend on details. When you think about it, this sounds like common sense. Practically, though, we might miss these details when we work on our software architecture.

The Challenge

We will again take into consideration our Logger example for this scenario. The Dependency Inversion Principle isn’t as obvious during implementation as the other principles.

In this example, consider an errorDecorator The above scenario works fine as long as you don't need to switch to a different logger in the near future. But let's say you do — for better compatibility, pricing, etc. The immediate solution then would be to simply use a RedisLog class instead of GrayLog. But the RedisLog implementation is probably different from that of GrayLog - perhaps it uses the sendLog function instead of saveLog and accepts a string parameter instead of an object param.

Then we change it’s implementation to input as a string at Line 9.

class RedisLog {
  sendLog(logMessage: string) {
    console.log(`Log Sent to Redis for logMessage`);
  }
}

const errorDecorator = (error: Error) => {
  const log = new RedisLog();
  log.sendLog(JSON.stringify(error));
};

const main = () => {
  errorDecorator(new Error("Error Message"));
};

main();

Now, the above case is a simple one with 2 minor changes — method name and its parameters. But practically, there might be a number of changes with functions added/removed and parameters modified. This isn’t an ideal approach, since this would affect a number of code changes at the implementation level.

Solution

Going a little deeper, we see that the issue arises because our errorDecorator function (which can be a class too) depends on the low-level implementation details of Loggers available. We now know that the Dependency Inversion principle recommends relying on high-level abstractions instead of low-level implementation details.

So, let’s create an abstract module instead which should be the dependency of our errorDecorator function:

abstract class LoggerService {
  createLog: (logObject: object) => void;
}

That’s it — the LoggerService takes a log object in its createLog function, and this can be implemented by any external logger API. For GrayLogwe can use GrayLoggerService, for RedisLogcreate a RedisLoggerServiceimplementation and so on.

class GrayLoggerService implements LoggerService {
  createLog(logObject: object) {
    const grayLog = new GrayLog();
    grayLog.saveLog(logObject);
  }
}

class RedisLoggerService implements LoggerService {
  createLog(logObject: object) {
    const logMessage = JSON.stringify(logObject);
    const redisLog = new RedisLog();
    redisLog.sendLog(logMessage);
  }
}

Instead of changing multiple implementation details, we have our separate LoggerServices which can be injected into the errorDecorator function.

const errorDecorator = (error: Error, loggerService: LoggerService) => {
  loggerService.createLog(error);
};

const main = () => {
  errorDecorator(new Error("Error Message"), new RedisLoggerService());
};

main();

In the above solution, you can see that the errorDecorator is not dependent on any low-level implementation modules such as GrayLog or RedisLog but is completely decoupled from the implementation. Additionally, by adhering to this we implicitly follow the Open/Closed principle since it is open to extension and closed to modification.

Summary

The Dependency Inversion principle is probably most critical of all the SOLID principles. This is because it's not an obvious choice at first to abstract out the Service layers that are needed for low-level implementations. The idea, usually, is to look at low-level implementations first, and then work backwards to generalization, instead of the other way round.

Do checkout Dependency Injection, Adapter Pattern, Service Locator Pattern etc.- these are implementations of the Dependency Inversion Principle itself.

Conclusion

In this part, we went through practical scenarios of using all the design principles of SOLID using typescript language. The examples were simplified for learning purposes but dealt with various challenges faced in real software design. The upcoming parts will deal with more advanced design patterns such as creational and structural patterns.

Category
Technology
Published On
15 Oct, 2020
Share

Subscribe to our tech, design & culture updates at Proximity

Weekly updates, insights & news across all teams

Copyright © 2019-2023 Proximity Works™