DEV Community

loading...
Cover image for 3 Design Patterns in TypeScript for Frontend Developers

3 Design Patterns in TypeScript for Frontend Developers

OpenReplay Tech Blog
Tech blog for Asayer.io. Quality content by developers for developers interested in JavaScript and related front-end technologies.
Originally published at blog.openreplay.com ・7 min read

by author Samaila Bala

Design Patterns are best software practices used by Software Developers in solving recuring 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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));
});
Enter fullscreen mode Exit fullscreen mode

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

Debugging a web application in production may be challenging and time-consuming. OpenReplay is an Open-source alternative to FullStory, LogRocket and Hotjar. It allows you to monitor and replay everything your users do and shows how your app behaves for every issue.
It’s like having your browser’s inspector open while looking over your user’s shoulder.
OpenReplay is the only open-source alternative currently available.

OpenReplay

Happy debugging, for modern frontend teams - Start monitoring your web app 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
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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');
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

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.

Discussion (0)