Ganesh Mani I'm a full-stack developer, Android application/game developer, and tech enthusiast who loves to work with current technologies in web, mobile, the IoT, machine learning, and data science.

Design patterns in TypeScript and Node.js

9 min read 2647

TypeScript and Node logos above a white background.

Design patterns are solutions to recurring problems in software application development.

As we all know, there are three types of design patterns. They are:

  • Creational
  • Structural
  • Behavioral

But, wait. what does that mean?

Creational pattern is concerned with the way we create objects in an object-oriented style. it applies patterns in the way we instantiate a class.

Structural pattern is concerned with how our classes and objects are composed to form a larger structure in our application.

Behavioral pattern is concerned about how the objects can interact efficiently without being tightly coupled.

A flow chart displaying the three kinds of design patterns we can choose from.

This tutorial will explain to you some of the most common design patterns that you can use in your Node.js Application. we will be using Typescript to make the implementation easier.

Singleton

The singleton pattern implies that there should be only one instance for a class. In laymen’s terms, there should be only one president for a country at a time. By following this pattern, we can avoid having multiple instances for a particular class.

A good example of the Singleton pattern is database connection in our application. Having multiple instances of a database in our application makes an application unstable. So, the singleton pattern provides a solution to this problem by managing a single instance across the application.

We made a custom demo for .
No really. Click here to check it out.

The two features of the Singleton design pattern.

Let’s see how to implement the above example in Node.js with Typescript:

import {MongoClient,Db} from 'mongodb'
class DBInstance {

    private static instance: Db

    private constructor(){}

    static getInstance() {
        if(!this.instance){
            const URL = "mongodb://localhost:27017"
            const dbName = "sample"    
            MongoClient.connect(URL,(err,client) => {
                if (err) console.log("DB Error",err)
                const db = client.db(dbName);
                this.instance = db
            })

        }
        return this.instance
    }
}

export default DBInstance

Here, we have a class, DBInstance, with an attribute instance. In DBInstance, we have the static method getInstance where our main logic resides.

It checks if there is a database instance already. If there is, it will return that. Otherwise, it will create a database instance for us and return it.

Here’s an example of how we can use the singleton pattern inside our API routes:

import express,{  Application, Request, Response } from 'express'
import DBInstance from './helper/DB'
import bodyParser from 'body-parser'
const app = express()

async function start(){

    try{

        app.use(bodyParser.json())
        app.use(bodyParser.urlencoded({extended : true}))
        const db = await DBInstance.getInstance()

        app.get('/todos',async (req : Request,res : Response) => {
            try {
                const db = await DBInstance.getInstance()

                const todos = await db.collection('todo').find({}).toArray()
                res.status(200).json({success : true,data : todos})
            }
            catch(e){
                console.log("Error on fetching",e)
                res.status(500).json({ success : false,data : null })
            }
            
        })

        app.post('/todo',async (req : Request,res : Response) => {
            try {
                const db = await DBInstance.getInstance()

                const todo = req.body.todos
               const todoCollection =  await db.collection('todo').insertOne({ name : todo })

                res.status(200).json({ success : true,data : todoCollection })
            }
            catch(e){
                console.log("Error on Inserting",e)
                res.status(500).json({ success : false,data : null })
            }
        })

        app.listen(4000,() => {
            console.log("Server is running on PORT 4000")
        })
    }
    catch(e){
        console.log("Error while starting the server",e)
    }
}

start()

Abstract factory

Before getting into the explanation of abstract factory, I want you to know what it means by factory pattern.

Simple factory

To make it simpler, let me give you an analogy. Let’s say that you are hungry and want some food. You can either cook for yourself or you can order from a restaurant. In that way, you don’t need to learn or know how to cook to eat some food.

Similarly, the Factory pattern simply generates an object instance for a user without exposing any instantiation logic to the client.

Now that we know about the simple factory pattern, let’s come back to the abstract factory pattern. Extending our simple factory example, let’s say you are hungry and you’ve decided to order food from a restaurant. Based on your preference, you might order a different cuisine. Then, you might need to select the best restaurant based on the cuisine.

As you can see, there is a dependency between your food and the restaurant. Different restaurants are better for different cuisine.

Let’s implement an abstract factory pattern inside our Node.js Application. Now, we are going to build a laptop store with different kinds of computers. A few of the main components are Storage and Processor.

Let’s build an interface for it:

export default interface IStorage {
     getStorageType(): string
}
import IStorage from './IStorage'
export default interface IProcessor {
    attachStorage(storage : IStorage) : string

    showSpecs() : string
}

Next, let’s implement the storage and processor interfaces in classes:

import IProcessor from '../../Interface/IProcessor'
import IStorage from '../../Interface/IStorage'

export default class MacbookProcessor implements IProcessor {

    storage: string | undefined

    MacbookProcessor() {
        console.log("Macbook is built using apple silicon chips")    
    }

    attachStorage(storageAttached: IStorage) {
        this.storage = storageAttached.getStorageType()
        console.log("storageAttached",storageAttached.getStorageType())
        return this.storage+" Attached to Macbook"
    }
    showSpecs(): string {
        return this.toString()
    }

    toString() : string {
        return "AppleProcessor is created using Apple Silicon and "+this.storage;
    }

}
import IProcessor from '../../Interface/IProcessor'
import IStorage from '../../Interface/IStorage'

export default class MacbookStorage implements IStorage {

    storageSize: number

    constructor(storageSize : number) {
        this.storageSize = storageSize
        console.log(this.storageSize+" GB SSD is used")
    }

    getStorageType() {
        return  this.storageSize+"GB SSD"
    }

}

Now, we’ll create a factory interface, which has methods such as createProcessor and createStorage.

import IStorage from '../Interface/IStorage'
import IProcessor from '../Interface/IProcessor'

export default interface LaptopFactory {
    createProcessor() : IProcessor

    createStorage() : IStorage
}

Once the factory interface is created, implement it in the laptop class. Here, it will be:

import LaptopFactory from '../../factory/LaptopFactory'
import MacbookProcessor from './MacbookProcessor'
import MacbookStorage from './MacbookStorage'

export class Macbook implements LaptopFactory {
    storageSize: number;

    constructor(storage : number) {
        this.storageSize = storage
    }

    createProcessor() : any{
        return new MacbookProcessor()
    }

    createStorage(): any {
        return new MacbookStorage(this.storageSize)
    }
}

Finally, create a function that calls factory methods:

import LaptopFactory from '../factory/LaptopFactory'
import IProcessor from '../Interface/IProcessor'

export const buildLaptop =  (laptopFactory : LaptopFactory) : IProcessor => {
    const processor = laptopFactory.createProcessor()

    const storage = laptopFactory.createStorage()

    processor.attachStorage(storage)

    return processor
}

Builder pattern

The builder pattern allows you to create different flavors of an object without using a constructor in a class.

But why, can’t we just use a constructor?

Well, there is a problem with the constructor in certain scenarios. Let’s say that you have a User model and it has attributes such as:

export default class User {

    firstName: string
    lastName : string
    gender: string
    age: number
    address: string
    country: string
    isAdmin: boolean

    constructor(firstName,lastName,address,gender,age,country,isAdmin) {
        this.firstName = builder.firstName
        this.lastName = builder.lastName
        this.address = builder.address
        this.gender = builder.gender
        this.age = builder.age
        this.country = builder.country
        this.isAdmin = builder.isAdmin
    }

}

To use this, you may need to instantiate it like this:

const user = new User("","","","",22,"",false)

Here, we have a limited argument. However, it will be hard to maintain once the attributes increase. To solve this problem, we need the builder pattern.

Create a builder class like this:

import User from './User'

export default class UserBuilder {

    firstName = ""
    lastName = ""
    gender = ""
    age = 0
    address = ""
    country = ""
    isAdmin = false

    constructor(){
        
    }

    setFirstName(firstName: string){
        this.firstName = firstName
    }

    setLastName(lastName : string){
        this.lastName = lastName
    }

    setGender(gender : string){
        this.gender = gender
    }

    setAge(age : number){
        this.age = age
    }

    setAddress(address : string){
        this.address = address
    }

    setCountry(country : string){
        this.country = country
    }

    setAdmin(isAdmin: boolean){
        this.isAdmin = isAdmin
    }

    build() : User {
        return new User(this)
    }

    getAllValues(){
        return this
    }
}

Here, we use getter and setter to manage the attributes in our builder class. After that, use the builder class inside our model:

import UserBuilder from './UserBuilder'

export default class User {

    firstName: string
    lastName : string
    gender: string
    age: number
    address: string
    country: string
    isAdmin: boolean

    constructor(builder : UserBuilder) {
        this.firstName = builder.firstName
        this.lastName = builder.lastName
        this.address = builder.address
        this.gender = builder.gender
        this.age = builder.age
        this.country = builder.country
        this.isAdmin = builder.isAdmin
    }

}

Adapter

A classic example of an adapter pattern will be a differently shaped power socket. Sometimes, the socket and device plug doesn’t fit. To make sure it works, we will use an adapter. That’s exactly what we are going to do in the adapter pattern.

It is a process of wrapping the incompatible object in an adapter to make it compatible with another class.

So far we have seen an analogy to understand the adapter pattern. Let me give you a real use case where an adapter pattern can be a life saver.

Consider that we have a CustomerError class:

import IError from '../interface/IError'
export default class CustomError implements IError{

    message : string

    constructor(message : string){
        this.message = message
    }

    serialize() {
        return this.message
    }
}

Now, we are using this CustomError class across our application. After some time, we need to change the method in the class due to some reason.

New Custom Error class will be something like this:

export default class NewCustomError{

    message : string
    
    constructor(message : string){
        this.message = message    
    }

    withInfo() {
        return { message : this.message } 
    }
}

Our new change will crash the entire application since it changes the method. To solve this problem, the adapter pattern comes into play.

Let’s create an adapter class and solve this problem:

import NewCustomError from './NewCustomError'
// import CustomError from './CustomError'
export default class ErrorAdapter {
    message : string;
    constructor(message : string) {
        this.message = message
    }

    serialize() {
              // In future replace this function
        const e = new NewCustomError(this.message).withInfo()
        return e
    }

}

The serialize method is what we use in our entire application. Our application doesn’t need to know which class we are using. The Adapter class handles it for us.

Observer

An Observer pattern is a way to update the dependents when there is a state change in another object. it usually contains Observer and Observable. Observer subscribes to Observable and where there is a change, observable notifies the observers.

Observable.

To understand this concept, let’s take a real use case for the observer pattern:

Author.

Here, we have Author, Tweet, and follower entities. Followers can subscribe to the Author . Whenever there’s a new Tweet, the follower is updated.

Let’s implement it in our Node.js application:

import Tweet from "../module/Tweet";

export default interface IObserver {
    onTweet(tweet : Tweet): string
}
import Tweet from "../module/Tweet";

export default interface IObservable{

    sendTweet(tweet : Tweet): any
}

Here, we have the interface IObservable and IObserver, which has onTweet and sendTweet methods in it.

import IObservable from "../interface/IObservable";
import Tweet from "./Tweet";
import Follower from './Follower'
export default class Author implements IObservable {

    protected observers : Follower[] = []

    notify(tweet : Tweet){
        this.observers.forEach(observer => {
            observer.onTweet(tweet)
        })
    }

    subscribe(observer : Follower){
        this.observers.push(observer)
    }

    sendTweet(tweet : Tweet) {
        this.notify(tweet)
    }
}

Follower.ts

import IObserver from '../interface/IObserver'
import Author from './Author'
import Tweet from './Tweet'

export default class Follower implements IObserver {

    name : string

    constructor(name: string){
        this.name = name
    }

    onTweet(tweet: Tweet) {
        console.log( this.name+" you got tweet =>"+tweet.getMessage())
        return this.name+" you got tweet =>"+tweet.getMessage()
    }

}

And Tweet.ts:

export default class Tweet {

    message : string
    author: string

    constructor(message : string,author: string) {
        this.message = message
        this.author= author
    }

    getMessage() : string {
        return this.message+" Tweet from Author: "+this.author
    }
}

index.ts

import express,{  Application, Request, Response } from 'express'
// import DBInstance from './helper/DB'
import bodyParser from 'body-parser'
import Follower from './module/Follower'
import Author from './module/Author'
import Tweet from './module/Tweet'

const app = express()

async function start(){

    try{

        app.use(bodyParser.json())
        app.use(bodyParser.urlencoded({extended : true}))
        // const db = await DBInstance.getInstance()

        app.post('/activate',async (req : Request,res : Response) => {
            try {

                const follower1 = new Follower("Ganesh")
                const follower2 = new Follower("Doe")

                const author = new Author()

                author.subscribe(follower1)
                author.subscribe(follower2)

                author.sendTweet(
                   new Tweet("Welcome","Bruce Lee")
                )

                res.status(200).json({ success : true,data:null })

            }
            catch(e){
                console.log(e)
                res.status(500).json({ success : false,data : null })
            }
        })

        app.listen(4000,() => {
            console.log("Server is running on PORT 4000")
        })
    }
    catch(e){
        console.log("Error while starting the server",e)
    }
}

start()

Strategy pattern

The strategy pattern allows you to select an algorithm or strategy at runtime. The real use case for this scenario would be switching file storage strategy based on the file size.

Consider that you want to handle file storage based on file size in your application:

Uploading file size.

Here, we want to upload the file and decide the strategy based on the file size, which is a run time condition. Let’s implement this concept using a strategy pattern.

Create an interface that needs to be implemented by a Writer class:

export default interface IFileWriter {
    write(filepath: string | undefined) : boolean
}

After that, create a class to handle files if it’s larger in size:

import IFileWriter from '../interface/IFileWriter'

export default class AWSWriterWrapper implements IFileWriter {

    write() {
        console.log("Writing File to AWS S3")
        return true
    }
}

Then, create a class to handle files which are smaller in size:

import IFileWriter from '../interface/IFileWriter'

export default class DiskWriter implements IFileWriter {

    write(filepath : string) {
        console.log("Writing File to Disk",filepath)
        return true
    }
}

Once we have both of them, we need to create the client that can use any strategy in it:

import IFileWriter from '../interface/IFileWriter'

export default class Writer {

    protected writer
    constructor(writer: IFileWriter) {
        this.writer = writer
    }

    write(filepath : string) : boolean {
        return this.writer.write(filepath)
    }
}

Finally, we can use the strategy based on the condition that we have:

let size = 1000

                if(size < 1000){
                    const writer = new Writer(new DiskFileWriter())
                    writer.write("file path comes here")
                }
                else{
                    const writer = new Writer(new AWSFileWriter())
                    writer.write("writing the file to the cloud")
                }

Chain of responsibility

The chain of responsibility allows an object to go through a chain of conditions or functionalities. Instead of managing all the functionalities and conditions in one place, it splits into chains of conditions that an object must pass through.

One of the best example for this pattern is express middleware:

Express middleware.

We build functions as a middleware that is tied up with each request in the express. Our request must pass the condition inside the middleware. It is the best example of a chain of responsibility:

Facade

The facade pattern allows us to wrap similar functions or modules inside a single interface. That way, the client doesn’t need to know anything about how it works internally.

A good example of this would be booting up your computer. You don’t need to know what happens inside the computer when you turn it on. You just need to press a button. In that way, the facade pattern helps us do high-level logic without the need to implement everything by the client.

Let’s take a user profile as an example where we have functionalities as follows:

  •  Whenever a user account gets deactivated, we need to update the status and update the bank details.

Let’s use the facade pattern to implement this logic in our application:

import IUser from '../Interfaces/IUser'

export default class User {
    private firstName: string
    private lastName: string
    private bankDetails: string | null
    private age: number
    private role: string
    private isActive: boolean

    constructor({firstName,lastName,bankDetails,age,role,isActive} : IUser){
        this.firstName = firstName
        this.lastName = lastName
        this.bankDetails = bankDetails
        this.age = age
        this.role = role
        this.isActive = isActive
    }

    getBasicInfo() {
        return {
            firstName: this.firstName,
            lastName: this.lastName,
            age : this.age,
            role: this.role
        }
    }

    activateUser() {
        this.isActive = true
    }

    updateBankDetails(bankInfo: string | null) {
        this.bankDetails= bankInfo
    }

    getBankDetails(){
        return this.bankDetails
    }

    deactivateUser() {
        this.isActive = false
    }

    getAllDetails() {
        return this
    }
}
export default interface IUser {
    firstName: string
    lastName: string
    bankDetails: string
    age: number
    role: string
    isActive: boolean
}

Finally, our facade class will be:

import User from '../module/User'

export default class UserFacade{

    protected user: User
    constructor(user : User){
        this.user = user
    }

    activateUserAccount(bankInfo : string){
        this.user.activateUser()
        this.user.updateBankDetails(bankInfo)

        return this.user.getAllDetails()
    }

    deactivateUserAccount(){
        this.user.deactivateUser()
        this.user.updateBankDetails(null)
    }

}

Here, we combine the methods that we need to call whenever a user account gets deactivated.

The complete source code can be found here.

Conclusion

We have seen only the design patterns that are commonly used in application development. There are lot of other design patterns available in software development. Feel free to comment your favorite design pattern and its use case.

200’s only Monitor failed and slow network requests in production

Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third party services are successful, try LogRocket. https://logrocket.com/signup/

LogRocket is like a DVR for web apps, recording literally everything that happens on your site. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.

Writing a lot of TypeScript? Watch the recording of our recent TypeScript meetup to learn about writing more readable code.

TypeScript brings type safety to JavaScript. There can be a tension between type safety and readable code. Watch the recording for a deep dive on some new features of TypeScript 4.4.

LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. .
Ganesh Mani I'm a full-stack developer, Android application/game developer, and tech enthusiast who loves to work with current technologies in web, mobile, the IoT, machine learning, and data science.

6 Replies to “Design patterns in TypeScript and Node.js”

  1. Singleton is antipattern, actually. At least in the described form. Makes testing really hard, creates hidden dependencies…
    If you have a dependency container, it can handle providing single instance of a service. This is a better form of singleton.

  2. The Singleton getInstance() implementation as presented may not work as expected depending on how MongoClient.connect is implemented. If the callback for .connect is invoked some time later when the connection is established (as is typical), then “this.instance” will not be set in time for the “return this.instance” line. If the assumption or behavior here is that getInstance() should return a promise that resolves to the client connection then the MongoClient.connect call should be wrapped in a “new Promise(…)” that resolves to mongo client inside the connect callback or rejects with an error from the callback.

  3. Singleton and Builder design patterns do not go well in javascript.
    Javascript has object literals, which solves the verbosity constructors with long parameter lists, funnily enough you gave an example of this User taking IUser object.
    And singleton is just a convoluted way to do something that is already solved by javascript modules.
    Each module is already a singleton imported via a path.
    It can export a class through the module, but that’s just extra unnecessary code.

    Please keep the Java/C# design patterns out of idiomatic javascript.

    Also Utill classes are not idiomatic javascript which has first cass support for functions.

Leave a Reply