_
Design Patterns in TypeScript (Part 1: Creational)

That's me in 2019, at the very beginning of a two-hour lecture on design patterns at the company I was working at. Two hours. People survived — mostly.
I've been meaning to write this up properly ever since. The original talk used JavaScript, but TypeScript makes patterns much more readable, so that's what we're using here.
The topic is wide enough that I'm splitting it into three parts:
- Part 1: Creational patterns ← you are here
- Part 2: Structural patterns
- Part 3: Behavioral patterns
Let's start from the beginning.
What Is a Design Pattern?
A design pattern is a typical solution to a problem that comes up often in software design. Not a library you import or a framework you install — just a general approach, a way of thinking about a problem.
The idea was popularized by the "Gang of Four" book from 1994 — Design Patterns: Elements of Reusable Object-Oriented Software. The book described 23 patterns grouped into three categories. Most of them are still relevant today.
There are three reasons to know them:
- Proven solutions. Patterns have been tried and refined by many people across many projects. You're not reinventing the wheel.
- Standardized vocabulary. Saying "let's use a Factory here" is much faster than a five-minute explanation of what you mean.
- Better design instincts. Even when you don't apply a pattern directly, knowing them trains you to think about problems at the right level of abstraction.
Why Not to Overuse Them
Knowing patterns is useful. Applying them everywhere is not.
Patterns are solutions to specific problems. If you don't have the problem, applying the pattern adds complexity for no reason. There's even a term for this — "patternitis" — when developers start seeing patterns everywhere and implement them just to feel like they're writing "proper" code.
The classic example: Strategy pattern in modern languages can just be a function. Builder is overkill for an object with two optional fields. Singleton solves a real problem but also introduces global state, which causes its own problems.
The rule is simple: understand what problem each pattern solves, and only reach for it when you actually have that problem.
Three Categories
GoF patterns are split into three groups:
- Creational — how objects are created
- Structural — how objects are composed together
- Behavioral — how objects communicate and share responsibilities
This article covers Creational. There are five of them: Singleton, Factory Method, Abstract Factory, Prototype, and Builder.
Singleton
The problem: you need exactly one instance of a class — a logger, a database connection pool, a config store — and you want a single global point of access to it.
The solution: the class controls its own instantiation. The constructor is private, and a static method returns the same instance every time.
class Singleton {
private static instance: Singleton;
private constructor() {
console.log("Singleton instance created");
}
public static getInstance(): Singleton {
if (!Singleton.instance) {
Singleton.instance = new Singleton();
}
return Singleton.instance;
}
}
const a = Singleton.getInstance();
const b = Singleton.getInstance();
console.log(a === b); // true
The private constructor prevents new Singleton() from outside the class. getInstance() creates the instance on first call and returns the same one every time after that.
A real-world use case — a config manager that should be initialized once and accessed everywhere:
class ConfigManager {
private static instance: ConfigManager;
private config: Record<string, string> = {};
private constructor() {
this.config = {
apiUrl: process.env.API_URL ?? "http://localhost:3000",
debug: process.env.DEBUG ?? "false",
};
}
public static getInstance(): ConfigManager {
if (!ConfigManager.instance) {
ConfigManager.instance = new ConfigManager();
}
return ConfigManager.instance;
}
public get(key: string): string {
return this.config[key];
}
}
const config = ConfigManager.getInstance();
console.log(config.get("apiUrl"));
The core idea of Singleton is simple: don't let outside code create more than one instance. The private constructor approach is one way to enforce that. Another is to never export the class at all — only export the instance:
class Database {
private connection: string;
constructor() {
this.connection = "postgresql://localhost:5432/myapp";
console.log("Database connected");
}
query(sql: string): void {
console.log(`[${this.connection}] ${sql}`);
}
}
// Every file that imports `db` gets the same instance
export const db = new Database();
The class isn't exported, so new Database() is impossible outside the module. The module system caches the instance after the first import — every consumer gets the same object. No getInstance(), no private constructor. Works well for most cases.
Pros:
- Guarantees a single instance — shared resources like connection pools, config, or caches stay consistent
- No need to pass the instance through function parameters — accessible from anywhere
- Saves memory when the object is heavy to initialize
Cons:
- Global state — any code can mutate it, which makes the system harder to reason about
- Hard to test — global state persists between tests and is difficult to isolate
- Hidden dependencies — nothing in a function's signature reveals it depends on a Singleton
- Tight coupling — hard to swap the implementation (e.g., replace a real DB with a mock)
- The class-based form breaks SRP — the class is responsible for both its logic and its own lifecycle
Factory Method
The problem: you need to create objects, but the exact type depends on runtime conditions — user input, config, environment. Sprinkling new Dog() and new Cat() throughout the codebase makes the code hard to extend.
The solution: move the creation logic into a dedicated place. The caller doesn't deal with new directly — it asks for an object.
There are two levels of this idea.
Simple Factory
abstract class Animal {
constructor(protected name: string) {}
abstract sayName(): void;
}
class Dog extends Animal {
sayName(): void { console.log(`The dog's name is ${this.name}`); }
}
class Cat extends Animal {
sayName(): void { console.log(`The cat's name is ${this.name}`); }
}
class AnimalFactory {
private static readonly registry: Record<string, new (name: string) => Animal> = {
dog: Dog,
cat: Cat,
};
static create(type: string, name: string): Animal {
const AnimalClass = AnimalFactory.registry[type];
if (!AnimalClass) throw new Error(`Unknown animal type: ${type}`);
return new AnimalClass(name);
}
}
AnimalFactory.create("dog", "Hachiko").sayName();
AnimalFactory.create("cat", "Nyan").sayName();
A more realistic example — a notification system where the delivery channel comes from config or user preference:
interface Notification {
send(message: string): void;
}
class EmailNotification implements Notification {
constructor(private email: string) {}
send(message: string): void { console.log(`Email to ${this.email}: ${message}`); }
}
class SMSNotification implements Notification {
constructor(private phone: string) {}
send(message: string): void { console.log(`SMS to ${this.phone}: ${message}`); }
}
class PushNotification implements Notification {
constructor(private deviceToken: string) {}
send(message: string): void { console.log(`Push to ${this.deviceToken}: ${message}`); }
}
type NotificationChannel = "email" | "sms" | "push";
class NotificationFactory {
static create(channel: NotificationChannel, target: string): Notification {
switch (channel) {
case "email": return new EmailNotification(target);
case "sms": return new SMSNotification(target);
case "push": return new PushNotification(target);
}
}
}
const notification = NotificationFactory.create("email", "user@example.com");
notification.send("Your order has shipped!");
The caller just gets something that can send(). Adding a Slack channel means adding a class and one line in the switch — no changes to call sites.
Factory Method (GoF)
The GoF version goes further. Instead of one factory class with a switch, you define an abstract creator with a factory method that subclasses override:
interface Transport {
deliver(payload: string): void;
}
class Truck implements Transport {
deliver(payload: string): void { console.log(`Road delivery: ${payload}`); }
}
class Ship implements Transport {
deliver(payload: string): void { console.log(`Sea delivery: ${payload}`); }
}
abstract class Logistics {
abstract createTransport(): Transport; // subclasses decide what to create
planDelivery(payload: string): void {
const transport = this.createTransport();
transport.deliver(payload);
}
}
class RoadLogistics extends Logistics {
createTransport(): Transport { return new Truck(); }
}
class SeaLogistics extends Logistics {
createTransport(): Transport { return new Ship(); }
}
const logistics: Logistics = new RoadLogistics();
logistics.planDelivery("Order #123"); // Road delivery: Order #123
planDelivery doesn't know what createTransport() returns — the subclass decides. The base class can call the factory method in multiple places without knowing the concrete type. That's the key difference from Simple Factory: here the subclass is the choice, not a string parameter.
Pros:
- Decouples object creation from usage
- Easy to extend — add a new type by adding a subclass, not modifying existing code (Open/Closed Principle)
- The creator can use the factory method internally without caring about the concrete type
Cons:
- More classes overall — every new product needs a new creator subclass
- Simple Factory is often enough; Factory Method adds structure that may not be needed
- Can feel over-engineered for straightforward cases
Abstract Factory
The problem: you need to create families of related objects that must be used together. For example, UI components that should be consistent — a Windows button and a Windows checkbox go together; you shouldn't mix Windows buttons with Mac checkboxes.
The solution: an abstract factory that defines an interface for creating a family of products. Concrete factories implement that interface for each variant.
interface Button {
render(): void;
}
class WindowsButton implements Button {
render() { console.log("Windows Button"); }
}
class MacButton implements Button {
render() { console.log("Mac Button"); }
}
interface Checkbox {
render(): void;
}
class WindowsCheckbox implements Checkbox {
render() { console.log("Windows Checkbox"); }
}
class MacCheckbox implements Checkbox {
render() { console.log("Mac Checkbox"); }
}
interface GUIFactory {
createButton(): Button;
createCheckbox(): Checkbox;
}
class WindowsGUIFactory implements GUIFactory {
createButton() { return new WindowsButton(); }
createCheckbox() { return new WindowsCheckbox(); }
}
class MacGUIFactory implements GUIFactory {
createButton() { return new MacButton(); }
createCheckbox() { return new MacCheckbox(); }
}
const renderUI = (factory: GUIFactory) => {
const button = factory.createButton();
const checkbox = factory.createCheckbox();
button.render();
checkbox.render();
};
renderUI(new WindowsGUIFactory());
renderUI(new MacGUIFactory());
The caller (renderUI) never sees WindowsButton or MacCheckbox directly — it only talks to the factory interface. Swapping an entire UI theme means passing a different factory.
The difference from Factory Method: Factory Method creates one product. Abstract Factory creates a family of related products.
Pros:
- Guarantees product family consistency — you can't accidentally mix incompatible products
- Isolates concrete product classes from the client
- Easy to swap entire product families
Cons:
- A lot of classes and interfaces, even for simple cases
- Adding a new product type (e.g., a
Scrollbar) requires updating every concrete factory - Can feel over-engineered for small codebases
Prototype
The problem: you want to create a new object that is a copy or a variation of an existing one. Reconstructing it from scratch means knowing all its configuration details and repeating every initialization step — and some of that state may be private.
The solution: the object clones itself. clone() creates a new instance with the same state, which you can then modify as needed. The original acts as a prototype — a configured template for copies.
class Prototype {
private value = 1;
public clone(): Prototype {
return Object.assign(new Prototype(), this);
}
}
const original = new Prototype();
const copy = original.clone();
That's the concept. A more realistic example — application config that needs to be cloned per environment:
interface DatabaseConfig {
host: string;
port: number;
database: string;
ssl: boolean;
}
interface CacheConfig {
enabled: boolean;
ttl: number;
}
class ApplicationConfig {
constructor(
private database: DatabaseConfig,
private cache: CacheConfig,
private environment: string
) {}
public clone(): ApplicationConfig {
return new ApplicationConfig(
{ ...this.database },
{ ...this.cache },
this.environment
);
}
public cloneForEnvironment(env: string): ApplicationConfig {
const cloned = this.clone();
cloned.environment = env;
if (env === "production") {
cloned.cache.enabled = true;
cloned.cache.ttl = 3600;
} else if (env === "development") {
cloned.cache.enabled = false;
}
return cloned;
}
public getInfo(): string {
return `[${this.environment}] ${this.database.host}:${this.database.port}, cache: ${this.cache.enabled}`;
}
}
const baseConfig = new ApplicationConfig(
{ host: "localhost", port: 5432, database: "myapp", ssl: false },
{ enabled: false, ttl: 300 },
"development"
);
const prodConfig = baseConfig.cloneForEnvironment("production");
console.log(baseConfig.getInfo()); // [development] localhost:5432, cache: false
console.log(prodConfig.getInfo()); // [production] localhost:5432, cache: true
The key thing about clone() is that it should do a deep copy — make sure nested objects are copied too, not just their references.
Pros:
- Avoids re-running expensive initialization
- Clone and modify is cleaner than creating from scratch with many params
- Works well for objects with complex internal state
Cons:
- Circular references make deep cloning tricky
- Each class must implement its own
clone()— easy to forget or get wrong - Shallow vs deep copy is a common source of subtle bugs
Builder
The problem: constructing a complex object requires many steps or configurations. A constructor with ten parameters is hard to read, easy to misuse, and forces you to pass undefined for everything you don't need.
The solution: a Builder separates the construction process from the object itself. You configure it step by step via named methods, and call build() only when everything is set.
To see why this matters, look at what the constructor version looks like:
class User {
constructor(
public name: string,
public age?: number,
public phone?: string,
public address?: string,
public role?: string,
public newsletter?: boolean
) {}
}
// Which arg is which? You have to check the signature every time.
const user = new User("John", 25, "+1234567890", undefined, "admin", true);
That undefined in the middle is the smell. As optional fields grow, this becomes unreadable. Builder fixes it:
class UserBuilder {
name: string;
age?: number;
phone?: string;
address?: string;
role?: string;
newsletter?: boolean;
constructor(name: string) {
this.name = name;
}
setAge(value: number): UserBuilder { this.age = value; return this; }
setPhone(value: string): UserBuilder { this.phone = value; return this; }
setAddress(value: string): UserBuilder { this.address = value; return this; }
setRole(value: string): UserBuilder { this.role = value; return this; }
setNewsletter(value: boolean): UserBuilder { this.newsletter = value; return this; }
build(): User {
return new User(this);
}
}
class User {
private name: string;
private age?: number;
private phone?: string;
private address?: string;
private role?: string;
private newsletter?: boolean;
constructor(builder: UserBuilder) {
this.name = builder.name;
this.age = builder.age;
this.phone = builder.phone;
this.address = builder.address;
this.role = builder.role;
this.newsletter = builder.newsletter;
}
}
const user = new UserBuilder("John")
.setAge(25)
.setPhone("+1234567890")
.setAddress("123 Main St")
.setRole("admin")
.setNewsletter(true)
.build();
Each setter returns this, which enables the fluent chaining. You can include only the fields you need — no undefined noise in the constructor call.
You've probably seen this pattern in query builders like TypeORM or Knex:
const users = await userRepository
.createQueryBuilder("user")
.where("user.age > :age", { age: 18 })
.orderBy("user.name")
.limit(10)
.getMany();
Same idea — accumulate parameters, execute at the end.
Pros:
- Readable, self-documenting construction with named steps
- Optional fields are truly optional — no undefined placeholders
- Easy to add new fields without breaking existing call sites
Cons:
- Extra class just for building
- Slightly more verbose than a simple object spread for small cases
- If the builder is mutable and reused, you can accidentally share state
What's Next
That's all five Creational patterns. Not bad for one sitting 🎉
Next up is Part 2 — Structural patterns. Facade, Adapter, Proxy, Decorator, and a few others. Less about how objects are born, more about how they fit together and talk to each other.
See you there 👋