Avoiding death by thousands ifs with the strategy pattern
In this brief post we will see how to apply the strategy pattern to your frontend code, so you can forget those nasty ifs, and sleep at night.
Most of the time, in JavaScript and TypeScript codebases structured as procedural code, design patterns are often neglected in favor of spaghetti code which more often than not leads to an unmaintainable mess.
In this brief post we will see how to apply the strategy pattern to your frontend code, so you can forget those nasty ifs, and sleep at night.
What is the strategy pattern and when it is useful?
The strategy pattern is a software design pattern which lets you swap an algorithm at runtime.
For example, one common application for the strategy pattern is a piece of code which needs to behave differently depending on the context where it's executed.
To keep things practical, let's say you have a UserRegistry
which can read
and persist
users to a storage.
The problem is that you don't know beforehand what the storage is and where it will persist the data. In the context of a frontend application, we can imagine that at some point the storage could be localStorage
or even IndexedDB.
UserRegistry
should not care what the actual implementation of the storage is, as long as it conforms to a particular interface which exposes save and read methods.
Without the strategy pattern, the calling code might do something like:
if storage === "localStorage" {
// save and read logic specific for localStorage
}
else if storage === "IndexedDB" {
// save and read logic specific for IndexedDB
}
else if storage === "anotherStorage" {
// save and read logic specific for anotherStorage
}
Without going too far, you can easily guess what this code can become.
Personally, I'm ok with if
statements at the tactical level, but not at the strategic level:
-
Tactical level: a function doing a small computation, or checking if some input is present, or if it's of the expected shape. In this case it's ok to have a couple of
if
or anif
with an early return. -
Strategic level: a piece of code needs to behave differently depending on where it's called. In this case it's not feasible to handle conditions with an
if
.
Using if
statements at the strategic level can lead to an explosion of conditional logic. This is when the strategy pattern comes in.
Avoiding death by thousands ifs with the strategy pattern
Here's a simple example of the strategy pattern in TypeScript. I'm not a fan of JavaScript classes, and whenever possible I use objects as follows.
First off, we define an interface for a generic storage strategy:
interface StorageStrategy<TItem> {
get(id: string): TItem | null;
save(item: TItem): void;
}
This is an interface that concrete implementations can conform to.
Next up, we use the interface for a more specific strategy which, let's imagine, talks to localStorage
:
const LocalStorageStrategy: StorageStrategy<Person> = {
get(id: string) {
// Get from local storage
},
save(item: Person) {
// Save to localstorage
},
};
Let's now define an interface for our program, an imaginary registry to save and read entities from a storage:
interface Registry<TItem> {
storage: StorageStrategy<TItem> | null;
init(strategy: StorageStrategy<TItem>): void;
persist(item: TItem): void;
read(id: string): TItem | null;
}
Objects conforming to the Registry
interface can be initialized with a StorageStrategy
. The registry does not care what the concrete implementation of the strategy is, as long as it conforms to the StorageStrategy
interface.
Here's the concrete implementation of our program:
const UserRegistry: Registry<Person> = {
storage: null,
init(strategy) {
this.storage = strategy;
},
persist(item) {
this.storage?.save(item);
},
read(id) {
return this.storage?.get(id) || null;
},
};
Finally, this is how we can use our UserRegistry
. Here's the initialization where we pass the concrete implementation of the storage:
UserRegistry.init(LocalStorageStrategy);
How is this better than the if
version? Imagine that UserRegistry
is used in multiple pages of you application, and depending on where it's used, it should use a different type of storage. In a page you'll do the following:
UserRegistry.init(LocalStorageStrategy);
In another page instead you'll do the following:
UserRegistry.init(IndexedDBStrategy);
Let's also not forget that with this arrangement, you can change the underlying algorithm at runtime.
This is way better than:
if storage === "localStorage" {
// save and read logic specific for localStorage
}
else if storage === "IndexedDB" {
// save and read logic specific for IndexedDB
}
else if storage === "anotherStorage" {
// save and read logic specific for anotherStorage
}
Also, here's how we read data from the storage:
UserRegistry.read("15");
Here's instead how we persist data to the storage:
UserRegistry.persist({
id: "12",
name: "Blaise",
age: 11,
});
In both cases, read
and persist
interact with the concrete implementation of the storage.
This can be easily adapted to plain JavaScript if you have the need to do so.
Thanks for reading!
Suggested resources
- Practical Python Design Patterns by Wessel Badenhorst
- Design Patterns: Elements of Reusable Object-Oriented Software
- Fluent Python by Luciano Ramalho shows how to use the strategy pattern with functions in chapter 10