Elevate your programming skills with SOLID and Typescript

Explore the intersection of SOLID principles and Typescript for improved software development

Asa LeHolland
Modules&Methods
6 min readDec 16, 2022

--

SOLID is an acronym for the five principles of object-oriented design that was first described by Robert C. Martin. These principles are meant to help software developers design software systems that are easy to maintain, extend, and understand. These were drilled into my head during college, but I still find myself coming back to them as ways to approach new problems or refactor existing code.

Recently, I’ve been writing a lot in Typescript (ahhh that satisfaction of short feedback loops when I’ve made a type error!), and struggled to find a good reference document with examples of the SOLID principles. So, I figured to make it easier to find for others, I would create a reference document with a few Typescript examples. Without further ado, here you go:

S: Single Responsibility Principle

The “S” in SOLID states that a class or module should have only one reason to change. This means that a class or module should have a single, well-defined responsibility and should be designed to minimize the potential for it to be affected by changes in other parts of the system.

class UserAccount {
private userName: string;
private password: string;
private email: string;

constructor(userName: string, password: string, email: string) {
this.userName = userName;
this.password = password;
this.email = email;
}

public updatePassword(newPassword: string): void {
this.password = newPassword;
}

public updateEmail(newEmail: string): void {
this.email = newEmail;
}
}

In this example, the UserAccount class has two methods, updatePassword and updateEmail, each of which has a single responsibility: to update the user's password or email address, respectively. This design follows the Single Responsibility Principle, because each method has a clear, well-defined purpose, and changes to one method are unlikely to affect the other.

O: Open-Closed Principle

The “O” in SOLID states that a class should be open for extension but closed for modification. In other words, we should be able to add new functionality to a class without changing its existing code. Here is an example:

interface Shape {
area(): number;
}

class Rectangle implements Shape {
constructor(private width: number, private height: number) {}

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

class Circle implements Shape {
constructor(private radius: number) {}

area(): number {
return Math.PI * this.radius ** 2;
}
}

class AreaCalculator {
constructor(private shapes: Shape[]) {}

totalArea(): number {
let area = 0;
for (const shape of this.shapes) {
area += shape.area();
}
return area;
}
}

In this example, the Shape interface defines a single method, area(), which returns the area of the shape. The Rectangle and Circle classes both implement this interface, providing their own implementations of the area() method.

The AreaCalculator class takes an array of Shape objects and calculates the total area by summing the areas of each shape. This allows us to add new shapes to the system simply by creating a new class that implements the Shape interface and providing an implementation of the area() method. We don't need to modify the AreaCalculator class at all.

By following the Open-Closed Principle, we can design our classes to be more flexible and easier to extend. If we need to add a new type of shape to the system, we can do so without changing any existing code.

L: Liskov Substitution Principle

The Liskov Substitution Principle states that objects of a subclass should be able to be used in the same way as objects of the superclass. In other words, subclasses should be substitutable for their superclasses.

Here is an example of how the Liskov Substitution Principle can be applied in TypeScript:

class Animal {
constructor(private name: string) {}

makeNoise(): void {
console.log('Some generic animal noise');
}
}

class Dog extends Animal {
makeNoise(): void {
console.log('Woof!');
}
}

class Cat extends Animal {
makeNoise(): void {
console.log('Meow!');
}
}

function makeAnimalsNoise(animals: Animal[]): void {
for (const animal of animals) {
animal.makeNoise();
}
}

const animals = [new Dog('Fido'), new Cat('Fluffy')];
makeAnimalsNoise(animals);

In this example, the Animal class has a single method, makeNoise(), which makes a generic animal noise. The Dog and Cat classes both extend Animal and provide their own implementations of the makeNoise() method.

The makeAnimalsNoise() function takes an array of Animal objects and calls the makeNoise() method on each of them. This allows us to use objects of the Dog and Cat classes in the same way as we would use objects of the Animal class.

By following the Liskov Substitution Principle, we can design our class hierarchies to be more flexible and easier to extend. If we need to add a new type of animal to the system, we can do so by creating a new class that extends Animal and providing its own implementation of the makeNoise() method. As long as the new class follows the same contract as the Animal class, it can be used interchangeably with other Animal objects.

I: Interface Segregation Principle

The “I” in SOLID stands for the Interface Segregation Principle, which states that clients should not be forced to depend on interfaces they do not use. In other words, we should design our interfaces to be small and specific, rather than trying to provide too much functionality in a single interface.

Here is an example of how the Interface Segregation Principle can be applied in TypeScript:

interface Animal {
makeNoise(): void;
}

interface Flyable {
fly(): void;
}

interface Swimmable {
swim(): void;
}

class Dog implements Animal {
makeNoise(): void {
console.log('Woof!');
}
}

class Bird implements Animal, Flyable {
makeNoise(): void {
console.log('Tweet!');
}

fly(): void {
console.log('Flapping wings');
}
}

class Fish implements Animal, Swimmable {
makeNoise(): void {
console.log('Blub!');
}

swim(): void {
console.log('Swimming');
}
}

In this example, we have defined three interfaces: Animal, Flyable, and Swimmable. The Animal interface has a single method, makeNoise(), which makes a noise specific to the animal. The Flyable and Swimmable interfaces have a single method each, fly() and swim(), respectively.

The Dog, Bird, and Fish classes all implement one or more of these interfaces. For example, the Bird class implements both the Animal and Flyable interfaces, while the Fish class implements the Animal and Swimmable interfaces.

By following the Interface Segregation Principle, we can design our interfaces to be more specific and easier to use. Clients can depend on the interfaces they need without being forced to depend on interfaces they don’t use. This makes our code more flexible and easier to maintain.

D: Dependency Inversion Principle

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules, but rather both should depend on abstractions. In other words, we should design our code to depend on abstractions rather than concrete implementations.

Here is an example of how the Dependency Inversion Principle can be applied in TypeScript:

interface DataStore {
save(data: any): void;
load(): any;
}

class FileDataStore implements DataStore {
constructor(private fileName: string) {}

save(data: any): void {
// code to save data to a file
}

load(): any {
// code to load data from a file
}
}

class DatabaseDataStore implements DataStore {
constructor(private url: string) {}

save(data: any): void {
// code to save data to a database
}

load(): any {
// code to load data from a database
}
}

class DataService {
constructor(private dataStore: DataStore) {}

save(data: any): void {
this.dataStore.save(data);
}

load(): any {
return this.dataStore.load();
}
}

In this example, the DataStore interface defines two methods for saving and loading data. The FileDataStore and DatabaseDataStore classes both implement this interface, providing their own implementations of the methods for saving and loading data from a file or database, respectively.

The DataService class depends on the DataStore interface rather than a specific implementation of it. This allows us to use any class that implements the DataStore interface, such as FileDataStore or DatabaseDataStore, without changing the DataService class.

By following the Dependency Inversion Principle, we can design our code to be more flexible and easier to maintain. If we need to change the way data is stored, we can do so by implementing a different class that follows the same contract as the DataStore interface, rather than having to change the DataService class. This allows us to make changes to our code in a more modular and decoupled way.

Summary

There you have it — an example for each of the SOLID principles in Typescript.

Hopefully, this is a helpful reference for you.

If you have any comments or suggestions, let me know!

--

--