Elevate your programming skills with SOLID and Typescript
Explore the intersection of SOLID principles and Typescript for improved software development
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!