3 Design Patterns in TypeScript for Frontend Developers
Design Patterns are best software practices used by Software Developers in solving recurring problems in Software Development. They aren’t code-related but rather a blueprint to use in designing a solution for a myriad of use cases.
There are about 23 different Software Design Patterns put together by the Gang of Four that can be grouped into three different categories:
-
Creational Pattern: These are patterns that pertain to object creation and class instantiation they help in the reuse of an existing code. The major creational patterns are Factory Method, Abstract Factory, Builder, Prototype, and Singleton.
-
Structural Pattern: These are patterns that help simplify the design by identifying a way to create relationships among entities such as objects and classes. They are concerned with how classes and objects can be assembled into larger structures. Some of the design patterns that fall into this category are: Adapter, Decorator, and Proxy.
-
Behavioral Pattern: These are patterns that are concerned with responsibilities among objects in order to help increase flexibility in carrying out communication. Some of these patterns are Observer, Memento, and Iterator.
In this article, we will look at three different patterns and how to use each of these patterns with TypeScript. This article assumes the reader knows JavaScript and TypeScript to follow along although these concepts can also be applied to other Object-oriented programming languages.
Singleton
The Singleton pattern is one of the most common design patterns. According to Refactoring Guru:
Singleton is a creational design pattern that lets you ensure that a class has only one instance, while providing a global access point to this instance.
In a Singleton pattern, a class or object can only be instantiated once, and any repeated calls to the object or class will return the same instance. This single instance is what is refered to as a Singleton.
A good example of a Singleton in Frontend applications is the global store in popular state management libraries like Vuex and Redux. The global store is a singleton as it is accessible in various parts of the application and we can only have one instance of it.
A singleton can also be used to implement a Logger to manage logs across an application. The logger is a great choice as a singleton because we will always want all our logs in one place in case we need to track them. Let’s see how we can implement a Logger with a Singleton in TypeScript
class Logger {
private static instance: Logger;
private logStore:any = []
private constructor() { }
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
public log(item: any): void{
this.logStore.push(item)
}
public getLog():void{
console.log(this.logStore)
}
}
In the example above we’ve created a logger to log items across an application. The constructor is made private to prevent creating a new instance of the class with the new
keyword. The getInstance
method will only create a new instance if there isn’t an existing instance thereby obeying the singleton principle.
Let’s see how we can use the singleton created
const useLogger = Logger.getInstance()
useLogger.log('Log 1')
const anotherLogger = Logger.getInstance()
anotherLogger.log('Log 2')
useLogger.getLog()
If you run the program above you’ll notice anotherLogger
didn’t create another instance but rather used the existing instance.
As common as singletons are they tend to be considered as an anti-pattern in some circles because of how overused they are, and the fact that they introduce a global state into an application, so before you use a singleton please consider if that will be the best use case for what you are trying to implement.
Observer
The Observer pattern is pretty common in TypeScript. It specifies a one-to-many relationship between an object and its dependents such that when the object changes state it notifies the other objects that depend on it about the change in state. The observer pattern is also common in the major frontend frameworks and libraries as the whole concept of updating parts of a UI with response to events comes from it.
In TypeScript, the observer pattern provides a way for UI components to subscribe to changes in an object without direct coupling to classes. A perfect example of the Observer pattern is a Mailing list. If as a user you are subscribed to a mailing list it sends you messages so you don’t have to manually check for a new message from the subject. Let’s look at how to implement this in TypeScript
interface NotificationObserver {
onMessage(message: Message): string;
}
interface Notify {
sendMessage(message: Message): any;
}
class Message {
message: string;
constructor(message: string) {
this.message = message;
}
getMessage(): string {
return `${this.message} from publication`;
}
}
class User implements NotificationObserver {
element: Element;
constructor(element: Element) {
this.element = element;
}
onMessage(message: Message) {
return (this.element.innerHTML += `<li>you have a new message - ${message.getMessage()}</li>`);
}
}
class MailingList implements Notify {
protected observers: User[] = [];
notify(message: Message) {
this.observers.forEach((observer) => {
observer.onMessage(message);
});
}
subscribe(observer: User) {
this.observers.push(observer);
}
unsubscribe(observer: User) {
this.observers = this.observers.filter(
(subscriber) => subscriber !== observer
);
}
sendMessage(message: Message) {
this.notify(message);
}
}
const messageInput: Element = document.querySelector(".message-input");
const user1: Element = document.querySelector(".user1-messages");
const user2: Element = document.querySelector(".user2-messages");
const u1 = new User(user1);
const u2 = new User(user2);
const subscribeU1: Element = document.querySelector(".user1-subscribe");
const subscribeU2: Element = document.querySelector(".user2-subscribe");
const unSubscribeU1: Element = document.querySelector(".user1-unsubscribe");
const unSubscribeU2: Element = document.querySelector(".user2-unsubscribe");
const sendBtn: Element = document.querySelector(".send-btn");
const mailingList = new MailingList();
mailingList.subscribe(u1);
mailingList.subscribe(u2);
subscribeU1.addEventListener("click", () => {
mailingList.subscribe(u1);
});
subscribeU2.addEventListener("click", () => {
mailingList.subscribe(u2);
});
unSubscribeU1.addEventListener("click", () => {
mailingList.unsubscribe(u1);
});
unSubscribeU2.addEventListener("click", () => {
mailingList.unsubscribe(u2);
});
sendBtn.addEventListener("click", () => {
mailingList.sendMessage(new Message(messageInput.value));
});
In the example above the Notify
interface contains a method for sending out messages to subscribers. The NotificationObserver
checks to see if there are any messages and alert the subscribed users. The Message
class holds the message state and it notifies subscribed users whenever the message state changes thereby following the observer pattern. So users can choose to subscribe or unsubscribe to messages. A complete working example of the code is available here.
Open Source Session Replay
OpenReplay is an open-source, session replay suite that lets you see what users do on your web app, helping you troubleshoot issues faster. OpenReplay is self-hosted for full control over your data.
Start enjoying your debugging experience - start using OpenReplay for free.
Factory Method
The factory method is a creational pattern that deals with Object creation. It helps in encapsulating an object from the code that depends on it. This might be confusing so let me use an analogy to explain. Imagine having a vehicle plant that produces different vehicles and you start by producing sedans but later on you decide to go into the production of trucks, you’ll probably have to create a duplicate production system for the trucks, now let’s imagine you add SUVs and minivans to the mix. At this point, the production system becomes messy and repetitive.
In a factory pattern, we can abstract the common behavior among the vehicles like how the vehicle is made, into a separate interface object called Vehicle, and then allow the different implementations to implement this common behavior in their unique ways.
In frontend, a factory method pattern allows us to abstract common behavior among components, let’s imagine a Toast component that has a different behavior on Mobile and Desktop we can use TypeScript to create a toast interface that outlines the general behavior of the toast component
interface Toast {
template: string;
title: string;
body: string;
position: string;
visible: boolean;
hide(): void;
render(title: string, body: string, duration: number, position: string): string
}
After creating a common interface that contains the general behavior of the toast component, the next step is creating the different implementations (Mobile and Desktop) of the toast
interface
class MobileToast implements Toast {
title: string;
body: string;
duration: number;
visible = false;
position = "center"
template = `
<div class="mobile-toast">
<div class="mobile-toast--header">
<h2>${this.title}</h2>
<span>${this.duration}</span>
</div>
<hr/>
<p class="mobile-toast--body">
${this.message}
</p>
</div>
`;
hide() {
this.visible = false;
}
render(title: string, body: string, duration: number, position: string) {
this.title = title,
this.body = body
this.visible = false
this.duration = duration
this.position = "center"
return this.template
}
}
class DesktopToast implements Toast {
title: string;
body: string;
position: string
visible = false;
duration: number;
template = `
<div class="desktop-toast">
<div class="desktop-toast--header">
<h2>${this.title}</h2>
<span>${this.duration}</span>
</div>
<hr/>
<p class="mobile-toast--body">
${this.message}
</p>
</div>
`;
hide() {
this.visible = false;
}
render(title: string, body: string, duration: number, position: string) {
this.title = title,
this.body = body
this.visible = true
this.duration = duration
this.position = position
return this.template
}
}
As you can see from the code, the Mobile and Desktop have slightly different implementations but maintain the base behavior of the toast
interface. The next step is creating a factory class that the Client code will work with without having to worry about the different implementations we’ve created.
class ToastFactory {
createToast(type: 'mobile' | 'desktop'): Toast {
if (type === 'mobile') {
return new MobileToast();
} else {
return new DesktopToast();
}
}
}
The factory code will return the correct implementation of the Toast component based on the type that is passed to it as a parameter. At this point, we can write our client code to communicate with the ToastFactory
.
class App {
toast: Toast;
factory = new ToastFactory();
render() {
this.toast = this.factory.createToast(isMobile() ? 'mobile' : 'desktop');
if (this.toast.visible) {
this.toast.render('Toast Header', 'Toast body');
}
}
}
You can see the app isn’t worried about our earlier implementations. Yeah I know it is a lot of code but this process ensures that the component implementations are not directly coupled to the component itself. This makes it easier, in the long run, to extend the component without breaking the existing code.
Conclusion
We’ve looked at some design patterns and their implementations in TypeScript. As earlier mentioned these design patterns provide a blueprint to follow when faced with recurring problems in Software Development. The examples in the article should be used as a guide to get started. I can’t wait to see how you apply them to the applications you create.