Skip to content
sang.id.vn
Go back

Creational Design Patterns Explained with TypeScript

Tiếng Việt

Every time you write new Something() scattered across your codebase, you’re making a decision about object creation — whether you realize it or not. Creational patterns make those decisions explicit. Here are 8 patterns that cover how objects get born.

1. Singleton

One instance. That’s it. The whole system shares it, and nobody gets to make a second one. I reach for this when I need a single background process that monitors or handles something globally.

public class Singleton {
    private constructor() {} // Private constructor so other classes cannot create new instances

    // This is the simplest approach but the downside is the instance is created even if it may never be used
    // private static instance: Singleton = new Singleton();
    // public static getInstance() : Singleton {
    //     return Singleton.instance;
    // }

    // This approach only creates the instance when a class calls for it
    private static instance: Singleton;
    public static getInstance() : Singleton {
        if (!Singleton.instance) {
            Singleton.instance = new Singleton();
        }
        return Singleton.instance;
    }

    // Method
    public helloWorld() {
        console.log("Hello World");
    }
}
Singleton.getInstance().helloWorld(); // Result: "Hello World"

Note

JavaScript is single-threaded, so you don’t have to worry about two classes requesting the instance at the exact same time and accidentally spawning two of them. One less headache compared to languages like Java or C#.

2. Factory

Gives you objects on demand without you having to deal with the messy details of creating them. I use this mostly when I have a bunch of similar classes (usually implementing the same interface) and I want one clean entry point to get the right one.

2.1. Purpose

Think of it like a car showroom. Without the Factory pattern, the showroom pulls one of every car model from the warehouse just to show the customer one. That’s wasteful. With the Factory pattern, you only build what the customer actually asks for.

interface Car {
    viewCar(): void;
}
class Honda implements Car {
    viewCar() {
        console.log("Honda-chan");
    }
}
class Nexus implements Car {
    viewCar() {
        console.log("Nexus-chan");
    }
}
class Toyota implements Car {
    viewCar() {
        console.log("Toyota-chan");
    }
}
class CarFactory {
    public honda : Honda = new Honda();
    public nexus : Nexus = new Nexus();
    public toyota : Toyota = new Toyota();
}
var viewCar = new CarFactory();
car.honda.viewCar(); // Result: "Honda-chan"
class CarFactory {
    public car!: Car;
    constructor(carType : string) {
        switch(carType) {
            case "HONDA": {
                this.car = new Honda();
                break;
            }
            case "NEXUS": {
                this.car = new Nexus();
                break;
            }
            case "TOYOTA": {
                this.car = new Toyota();
            }
        }
    }
}
var car = new CarFactory("TOYOTA");
car.car.viewCar();

3. Factory Method

Factory Method solves a specific problem: you need to create objects, but you don’t want the calling code to know which concrete class gets instantiated. I find this really useful when building extensible systems.

3.1. Structure

The Product interface gets the most references in the diagram, so that’s where I start defining things. I like to think of the diagram in two halves: the top half is the contract between two managers, and the bottom half is their employees actually talking to each other through the terms of that contract.

interface Product {
    doStuff() : void;
}
class ConcreteProduct1 implements Product {
    doStuff() {
        return "Result of the ConcreteProduct1";
    }
}
class ConcreteProduct2 implements Product {
    doStuff() {
        return "Result of the ConcreteProduct2";
    }
}
abstract class Creator {
    public abstract createProduct() : Product;
    someOperation() {
        // Call the factory method to create a Product object.
        let product : Product = this.createProduct();
        // Now, use the product.
        return `Creator: The same creator's code has just worked with ${product.doStuff()}`;
    }
}
class ConcreteCreator1 extends Creator {
    public createProduct() {
        return new ConcreteProduct1();
    }
}
class ConcreteCreator2 extends Creator {
    public createProduct() {
        return new ConcreteProduct2();
    }
}
function clientCode(creator: Creator) {
    console.log(creator.someOperation());
}
console.log("App: Launched with the ConcreteCreator2\n");
clientCode(new ConcreteCreator2());
App: Launched with the ConcreteCreator2
The same creator's code has just worked with Result of the ConcreteProduct2

4. Abstract Factory

Abstract Factory wraps up a bunch of related, complex classes behind a single object. You’ve got classes that share some structure — maybe they all descend from the same abstract class — and you bundle the creation logic into one place. Everything happens inside that factory, and the consumer just gets back what they need. I think of it as the “family of products” pattern.

Usage frequency: Pretty common.

4.1. Structure

interface AbstractProductA {
    functionA(): string;
}
interface AbstractProductB {
    functionB(): string;
}
interface AbstractFactory {
    createProductA(): AbstractProductA;
    createProductB(): AbstractProductB;
}
class ConcreteProductA1 implements AbstractProductA {
    functionA(): string {
        return "The result of the product A1.";
    }
}
class ConcreteProductA2 implements AbstractProductA {
    functionA(): string {
        return "The result of the product A2.";
    }
}
class ConcreteProductB1 implements AbstractProductB {
    functionB(): string {
        return "The result of the product B1.";
    }
}

class ConcreteProductB2 implements AbstractProductB {
    functionB(): string {
        return "The result of the product B2.";
    }
}
class ConcreteFactory1 implements AbstractFactory {
    createProductA(): AbstractProductA {
        return new ConcreteProductA1();
    }

    createProductB(): AbstractProductB {
        return new ConcreteProductB1();
    }
}
class ConcreteFactory2 implements AbstractFactory {
    createProductA(): AbstractProductA {
        return new ConcreteProductA2();
    }

    createProductB(): AbstractProductB {
        return new ConcreteProductB2();
    }
}
function clientCode(factory: AbstractFactory) {
    let product_a = factory.createProductA();
    let product_b = factory.createProductB();

    console.log(product_b.functionB());
}

console.log("Client: Testing client code with the first factory type");
clientCode(new ConcreteFactory1());
console.log("Client: Testing the same client code with the second factory type");
clientCode(new ConcreteFactory2());
Client: Testing client code with the first factory type
The result of the product B1.
Client: Testing the same client code with the second factory type
The result of the product B2.

Takeaways:

4.2. Differentiating Abstract Factory and Factory Method

4.2.1. Similarities

4.2.2. Differences

Factory Method
Abstract Factory

4.3. When to use Abstract Factory vs Factory Method?

Source: Head First - Design Patterns book

5. Builder

Builder lets you construct complex objects step by step, using simple building blocks. Each step is independent. I reach for this whenever I see a constructor with too many parameters — that “telescoping constructor” problem where you keep adding parameters and it gets unreadable.

5.1. Advantages

5.2. Limitations

Usage frequency: Common

5.3. Main Components

5.4. Structure

interface Builder gets the most references in the diagram, so I start there. But first you need the Product definition — you can’t build a builder without knowing what it’s building.

class Product {
    constructor(
        private partA: string,
        private partB: string,
        private partC: string
    ) { }
    show() : string {
        return `This product has 3 parts: ${this.partA}, ${this.partB} and ${this.partC}`;
    }
}
abstract class Builder {
    abstract BuildPartA(content: string): Builder;
    abstract BuildPartB(content: string): Builder;
    abstract BuildPartC(content: string): Builder;
    abstract GetResult(): Product;
}
class ConcreteBuilder1 extends Builder {
    private partA! : string;
    private partB! : string;
    private partC! : string;
    BuildPartA(content: string) : Builder {
        this.partA = content;
        return this; // Used for method chaining when client uses it
    }
    BuildPartB(content: string) : Builder {
        this.partB = content;
        return this;
    }
    BuildPartC(content: string) : Builder {
        this.partC = content;
        return this;
    }
    GetResult() : Product {
        return new Product(this.partA, this.partB, this.partC);
    }
}
class Director {
    private product! : Product;
    constructor(private builder : Builder) {
        this.product = this.builder.GetResult();
    }
    showProduct() {
        return this.product.show();
    }
}
let b1 : Builder = new ConcreteBuilder1();
let director : Director = new Director(
    b1.BuildPartA('Ngô')
    .BuildPartB('Quang')
    .BuildPartC('Sang') // Method chaining
);
console.log(director.showProduct());
This product has 3 parts: Ngô, Quang and Sang

6. Object Pool

Object Pool manages a cache of reusable objects. Instead of creating something new every time, clients just grab an existing one from the pool. The pool either auto-creates objects when empty or caps out at a fixed number. I’ve seen this pattern a lot in connection pooling for databases.

6.1. Example

Think of it like an office supply warehouse. New hire shows up, the manager checks the warehouse for spare equipment. Got some? Great, grab it. Don’t have any? Order new stuff. Employee gets fired? Their gear goes back to the warehouse for the next person.

This analogy also highlights the problems. First, the pool itself costs resources — you need a warehouse (space, money, someone to manage it). Second, stuff gets stale. You need an iPhone for a new employee, but the warehouse only has a Nokia 1080. RIP. You’re paying for storage you can’t even use.

The general idea: if instances of a class can be reused, reuse them instead of creating new ones.

6.2. Advantages

6.3. Disadvantages

6.4. When to use Object Pool

6.5. Structure

6.6. Object Pool Example via a Taxi Application

Picture a taxi company with only N cars. The company tracks which cars are free vs. carrying passengers, dispatches idle cars to new customers, makes customers wait if all cars are busy, and cancels if the wait gets too long.

Here’s how I modeled it:

In the code below, I simulate a pool of 4 taxis with 8 simultaneous customer calls. Each taxi takes 200ms to arrive, carries passengers for 1000-1500ms (random), and each customer waits a max of 1200ms before giving up.

class Taxi {
    constructor(private name: string) {}
    getName() : String {
        return this.name;
    }
    setName(name: string) {
        this.name = name;
    }
    toString() : String {
        return `Taxi [name = ${this.name}]`
    }
}
class TaxiPool {
    private readonly EXPIRED_TIME_IN_MILISECOND: number = 1200;
    private readonly NUMBER_OF_TAXI: number = 4;
    private available: Array<Taxi> = new Array<Taxi>();
    private inUse: Array<Taxi> = new Array<Taxi>();
    private count: number = 0;
    private waiting: boolean = false;

    async getTaxi(): Promise<Taxi> {
        if (this.available.length > 0) {
            let taxi: Taxi = this.available.shift() || new Taxi('');
            this.inUse.push(taxi);
            return Promise.resolve(taxi);
        }
        if (this.count == this.NUMBER_OF_TAXI) {
            this.waitingUntilTaxiAvailable();
            return this.getTaxi();
        }
        return await this.createTaxi();
    }
    release(taxi: Taxi) {
        this.inUse.splice(this.inUse.indexOf(taxi), 1);
        this.available.push(taxi);
        console.log(taxi.getName() + " is free");
    }
    async createTaxi(): Promise<Taxi> {
        await this.sleep(200);
        this.count++;
        let taxi: Taxi = new Taxi("Taxi " + this.count);
        console.log(taxi.getName() + " is created");
        return taxi;
    }
    async waitingUntilTaxiAvailable(): Promise<void> {
        if (this.waiting) {
            this.waiting = false;
            throw new Error("No taxi available");
        }
        this.waiting = true;
        await this.waitingF(this.EXPIRED_TIME_IN_MILISECOND);
    }
    async waitingF(numberOfSecond: number) {
        try {
            await this.sleep(numberOfSecond);
        } catch (e) {
            throw new Error(e);
        }
    }
    sleep(ms: any): Promise<any> {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
}
class ClientThread {
    private clientNumber: number = Math.floor(Math.random() * 501) + 1000;
    constructor(private taxiPool: TaxiPool) { }

    run(): void {
        this.takeATaxi();
    }

    private async takeATaxi(): Promise<void> {
        try {
            console.log("New client: " + this.clientNumber);
            let taxi: Taxi;
            taxi = await this.taxiPool.getTaxi().then(res => res);

            await this.sleep(Math.floor(Math.random() * 501) + 1000);

            this.taxiPool.release(taxi);
            console.log("Served the client: " + this.clientNumber);
        } catch (e) {
            console.log(">>>Rejected the client: " + this.clientNumber);
        }
    }

    sleep(ms: any): Promise<any> {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
}
const NUM_OF_CLIENT: number = 8;
let taxiPool: TaxiPool = new TaxiPool();
for (let i = 0; i < NUM_OF_CLIENT; i++) {
    let client: ClientThread = new ClientThread(taxiPool);
    client.run();
}
New client: 1196
New client: 1346
New client: 1398
New client: 1052
New client: 1132
New client: 1280
New client: 1174
New client: 1247
Taxi 1 is created
Taxi 2 is created
Taxi 3 is created
Taxi 4 is created
Taxi 5 is created
Taxi 6 is created
Taxi 7 is created
Taxi 8 is created
Taxi 8 is free
Served the client: 1247
Taxi 5 is free
Served the client: 1132
Taxi 6 is free
Served the client: 1280
Taxi 3 is free
Served the client: 1398
Taxi 4 is free
Served the client: 1052
Taxi 2 is free
Served the client: 1346
Taxi 7 is free
Served the client: 1174
Taxi 1 is free
Served the client: 1196

Observations

Reference: https://gpcoder.com/4456-huong-dan-java-design-pattern-object-pool/

7. Prototype

Prototype creates new objects by copying an existing one. Instead of building from scratch, you clone a template. I use this a lot more than I expected to.

Usage frequency: High

7.1. Structure

7.2. Participating Components

class CustomerPrototype {
    constructor(public proto: any) { }
    clone() {
        var customer: Customer = new Customer(
            this.proto.first,
            this.proto.last,
            this.proto.status
        );
        return customer;
    };
}

class Customer {
    constructor(
        public first: string,
        public last: string,
        public status: string
    ) { }
    say() {
        console.log(
            `name: ${this.first} ${this.last}, status: ${this.status}`
        );
    }
}
let proto = new Customer("n/a", "n/a", "pending");
let prototype = new CustomerPrototype(proto);

let customer = prototype.clone();
customer.say();
name: n/a n/a, status: pending
let prototye : {
    name: "John"
}
let custom = {...prototype};

Here’s something that tripped me up early on. Every instance inherits properties and methods from its class, which means each instance eats memory for its own copy. Prototype Pattern has a variant that solves this by sharing properties across instances.

function Warrior(name) {
  this.name = name;
  this.hp = 100;
  this.bash = function (target) {
    target.hp -= 10;
  };
  this.slash = function (target) {
    target.hp /= 2;
  };
}

let harryPotter = new Warrior("Harry Potter");
let ngan = new Warrior("Ngan");
let snake = new Warrior("Snake");

harryPotter.bash(ngan);
console.log(ngan.hp); // 90
ngan.slash(snake);
console.log(snake.hp); // 50
console.log(
  harryPotter.constructor === ngan.constructor,
  snake.constructor === ngan.constructor
); // Result: true true
console.log(harryPotter.bash === ngan.bash, snake.bash === ngan.bash); // Result: false false
function Warrior(name) {
  this.name = name;
  this.hp = 100;
}

Warrior.prototype.bash = function (target) {
  target.hp -= 10;
};

Warrior.prototype.slash = function (target) {
  target.hp /= 2;
};

let harryPotter = new Warrior("Harry Potter");
let ngan = new Warrior("Ngan");
let snake = new Warrior("Snake");

console.log(harryPotter.bash === ngan.bash, snake.bash === ngan.bash); // Result: true, true
Warrior.prototype = {
  bash: function (target) {
    target.hp -= 10;
  },

  slash: function (target) {
    target.hp /= 2;
  },
};

Watch out though — this approach replaces the entire prototype object, which means you lose the original Warrior constructor reference. But it shows exactly how prototype-based inheritance and sharing works in JavaScript.

8. Dependency Injection

Instead of letting a class create its own dependencies internally, you declare the dependency as an interface and pass the concrete instance in from outside. This makes everything more flexible and testable.

8.1. Advantages

8.2. Problem Statement

Say you have a motorcycle. It has parts, like tires. There are different tire types at different prices — type A at $1000, type B at $500. You picked type B to save money.

class LopA {
    public price : number = 1000;
}
class LopB {
    public price : number = 500;
}
class Xe {
    public lop : LopA = new LopA();
}

When you create a motorcycle object, the class internally creates a tire instance. That hard dependency causes two problems:

8.3. Solution

DI fixes this cleanly. The key insight: declare your dependency as an interface type. Don’t hard-wire it to a class. Don’t create instances inside the constructor. Instead, create the instance outside and pass it in. This kills the tight coupling.

Back to the example — instead of pre-creating a tire inside Xe, just declare the interface type and inject the concrete instance from outside:

interface Lop {
    price: number;
}
class LopA implements Lop {
    public price : number = 1000;
}
class LopB implements Lop {
    public price : number = 500;
}
class Xe {
    constructor(public lop: Lop) {}
}

// run
let xe : Xe = new Xe(new LopB());
console.log(xe.lop.price); // Result: 500

Want to upgrade from type B to type A? Just do this:

xe.lop = new LopA();
console.log(xe.lop.price); // Result: 1000

Switched tires without touching the Xe class at all. That’s SOLID in action.

8.4. Types of DI

DI comes in two flavors:

class Xe {
    public _lop!: Lop;
    set lop(lop: Lop) {
        this._lop = lop;
    }
}
let xe : Xe = new Xe();
xe.lop = new LopB();

Share this post on:

Previous Post
Behavioral Design Patterns Explained with TypeScript
Next Post
Structural Design Patterns Explained with TypeScript