SOLID Principles using Typescript

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

Yatin
Proximity Works
9 min readOct 19, 2020

--

Over the years, we’ve observed the same set of problems occur over and over again in software design. Solutions have emerged too — reusable solutions known as Design Patterns. But before we get into that, we ought 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 —

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:

Code without Single Responsibility

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 — 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 it isn’t really a responsibility for fetchPosts.
Code following the Single Responsibility Principle

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 the 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 the sendEmail on EmailService.

Notification Service code without Open/Closed principle, example 1

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.

Notification Service code without Open/Closed principle, example 2

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 base NotificationService class.

Example Typescript code for Open/Closed Principle

In the above solution, rather than modifying the NotificationService class, we create a separate OrderNotificationService class. This extends the generic NotificationService and instantiates the SMSService 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 modules modification 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 Liskov 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

Custom Error abstraction using Typescript

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

Connection Error extending our CustomError class

But the DatabaseError class is also implemented similarly, except for one requirement change wherein the database error — being critical in nature also needs a createAlert method.

DBError extending CustomError class

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:

Workaround for Liskov Substitution Principle

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 “has/a” class type instead of “is/a” class type. Let’s take a look at an example to understand this better:

Liskov Substitution Principle in action

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.

PaymentProvider Interface

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.

CreditCardPayment Provider with fake verify payment method

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:

WalletPaymentProvider with fake validate method

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.

Segregated Payment Provider

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

CreditCardPaymentProvider without fake verifyPayment method

Similarly, the WalletPaymentProvider is also fixed with the class now implementing:
1. PaymentProvider interface, and
2. PaymentVerifier interface

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 function which takes in an error as a parameter. The developer is using Graylog for saving logs, and so we instantiate the Graylog class, saving logs using its saveLog method.

Saving Logs via GrayLog logger

Where this Fails

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.

Error Decorator using RedisLog for saving logs

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:

LoggerService High-Level Abstraction

That’s it — the LoggerService takes a log object in its createLog function, and this can be implemented by any external logger API. For GrayLog we can use GrayLoggerService, for RedisLog create a RedisLoggerService implementation and so on.

LoggerService Implementation

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

LoggerService injected into errorDecorator

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 which 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 check out 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 deal with various examples faced in real software design instead of just using Animal or Rectangle classes. Upcoming parts will deal with more advanced design patterns such as creational and structural patterns.

Update:

The second article in this series of Blog Posts for understanding Design Patterns using Typescript is live. It is Creational Design Patterns using Typescript.

--

--