⏱️7 min read · 1,358 words
TypeScript design patterns in 2026 leverage the full power of the type system to create self-documenting, safe, and extensible code. This guide covers the most valuable patterns for real-world TypeScript applications, from basic creational patterns to advanced functional techniques.
📋 Table of Contents
Singleton Pattern
class DatabasePool {
private static instance: DatabasePool | null = null;
private connections: Connection[] = [];
private constructor(private readonly config: DBConfig) {
// Private constructor prevents direct instantiation
}
static getInstance(config?: DBConfig): DatabasePool {
if (!DatabasePool.instance) {
if (!config) throw new Error("Config required for first initialization");
DatabasePool.instance = new DatabasePool(config);
}
return DatabasePool.instance;
}
async query<T>(sql: string, params: unknown[]): Promise<T[]> {
const conn = await this.acquire();
try {
return await conn.execute<T>(sql, params);
} finally {
this.release(conn);
}
}
private async acquire(): Promise<Connection> {
// Get available connection from pool
return this.connections.pop() ?? await createConnection(this.config);
}
private release(conn: Connection): void {
this.connections.push(conn);
}
}
// Usage
const db = DatabasePool.getInstance({ host: "localhost", port: 5432 });
const users = await db.query<User>("SELECT * FROM users WHERE id = $1", [1]);
Builder Pattern
interface QueryConfig {
table: string;
conditions: string[];
columns: string[];
limit?: number;
offset?: number;
orderBy?: { column: string; direction: "ASC" | "DESC" };
}
class QueryBuilder {
private config: QueryConfig;
constructor(table: string) {
this.config = { table, conditions: [], columns: [] };
}
select(...columns: string[]): this {
this.config.columns.push(...columns);
return this;
}
where(condition: string): this {
this.config.conditions.push(condition);
return this;
}
orderBy(column: string, direction: "ASC" | "DESC" = "ASC"): this {
this.config.orderBy = { column, direction };
return this;
}
limit(n: number): this {
this.config.limit = n;
return this;
}
offset(n: number): this {
this.config.offset = n;
return this;
}
build(): string {
const cols = this.config.columns.length ? this.config.columns.join(", ") : "*";
let query = `SELECT ${cols} FROM ${this.config.table}`;
if (this.config.conditions.length) {
query += ` WHERE ${this.config.conditions.join(" AND ")}`;
}
if (this.config.orderBy) {
query += ` ORDER BY ${this.config.orderBy.column} ${this.config.orderBy.direction}`;
}
if (this.config.limit) query += ` LIMIT ${this.config.limit}`;
if (this.config.offset) query += ` OFFSET ${this.config.offset}`;
return query;
}
}
const query = new QueryBuilder("users")
.select("id", "name", "email")
.where("active = true")
.where("role = 'admin'")
.orderBy("created_at", "DESC")
.limit(10)
.build();
Factory Pattern
interface NotificationSender {
send(to: string, message: string): Promise<void>;
}
class EmailSender implements NotificationSender {
async send(to: string, message: string): Promise<void> {
await sendEmail({ to, subject: "Notification", body: message });
}
}
class SMSSender implements NotificationSender {
async send(to: string, message: string): Promise<void> {
await sendSMS({ to, message: message.slice(0, 160) });
}
}
class PushSender implements NotificationSender {
async send(to: string, message: string): Promise<void> {
await sendPushNotification({ userId: to, body: message });
}
}
type NotificationType = "email" | "sms" | "push";
function createNotificationSender(type: NotificationType): NotificationSender {
const senders: Record<NotificationType, () => NotificationSender> = {
email: () => new EmailSender(),
sms: () => new SMSSender(),
push: () => new PushSender(),
};
const factory = senders[type];
if (!factory) throw new Error(`Unknown notification type: ${type}`);
return factory();
}
// Usage
const sender = createNotificationSender("email");
await sender.send("alice@example.com", "Your order has shipped!");
Observer / Event Emitter Pattern
type EventMap = {
"user:created": { id: number; email: string };
"order:placed": { orderId: string; total: number; userId: number };
"payment:failed": { orderId: string; error: string };
};
type EventHandler<T> = (data: T) => void | Promise<void>;
class TypedEventEmitter<Events extends Record<string, unknown>> {
private handlers: Partial<{
[K in keyof Events]: EventHandler<Events[K]>[];
}> = {};
on<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): this {
if (!this.handlers[event]) {
(this.handlers[event] as EventHandler<Events[K]>[]) = [];
}
(this.handlers[event] as EventHandler<Events[K]>[]).push(handler);
return this;
}
off<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): this {
const handlers = this.handlers[event] as EventHandler<Events[K]>[] | undefined;
if (handlers) {
(this.handlers[event] as EventHandler<Events[K]>[]) = handlers.filter(h => h !== handler);
}
return this;
}
async emit<K extends keyof Events>(event: K, data: Events[K]): Promise<void> {
const handlers = (this.handlers[event] as EventHandler<Events[K]>[] | undefined) ?? [];
await Promise.all(handlers.map(h => h(data)));
}
}
const emitter = new TypedEventEmitter<EventMap>();
emitter.on("user:created", async ({ email }) => {
await sendWelcomeEmail(email);
});
emitter.on("order:placed", async ({ orderId, userId }) => {
await sendOrderConfirmation(userId, orderId);
});
// TypeScript enforces correct event data types
await emitter.emit("user:created", { id: 1, email: "alice@example.com" });
// await emitter.emit("user:created", { id: 1 }); // TypeScript error!
Strategy Pattern
interface PaymentStrategy {
charge(amount: number, currency: string): Promise<PaymentResult>;
refund(transactionId: string, amount: number): Promise<void>;
}
class StripeStrategy implements PaymentStrategy {
constructor(private apiKey: string) {}
async charge(amount: number, currency: string): Promise<PaymentResult> {
const charge = await stripe.charges.create({ amount, currency });
return { transactionId: charge.id, status: "success" };
}
async refund(transactionId: string, amount: number): Promise<void> {
await stripe.refunds.create({ charge: transactionId, amount });
}
}
class PayPalStrategy implements PaymentStrategy {
async charge(amount: number, currency: string): Promise<PaymentResult> {
const order = await paypal.orders.create({ amount, currency });
return { transactionId: order.id, status: "success" };
}
async refund(transactionId: string, amount: number): Promise<void> {
await paypal.captures.refund(transactionId, { amount });
}
}
class PaymentProcessor {
constructor(private strategy: PaymentStrategy) {}
setStrategy(strategy: PaymentStrategy): void {
this.strategy = strategy;
}
async processPayment(amount: number, currency: string): Promise<PaymentResult> {
return this.strategy.charge(amount, currency);
}
}
// Switch strategy at runtime
const processor = new PaymentProcessor(new StripeStrategy(process.env.STRIPE_KEY!));
const result = await processor.processPayment(9999, "AUD");
// Switch to PayPal for specific users
processor.setStrategy(new PayPalStrategy());
Repository Pattern with Generics
interface Repository<T, ID = number> {
findById(id: ID): Promise<T | null>;
findAll(options?: FindOptions): Promise<T[]>;
create(data: Omit<T, "id" | "createdAt" | "updatedAt">): Promise<T>;
update(id: ID, data: Partial<T>): Promise<T>;
delete(id: ID): Promise<void>;
count(where?: Partial<T>): Promise<number>;
}
interface FindOptions {
limit?: number;
offset?: number;
orderBy?: string;
where?: Record<string, unknown>;
}
// Base implementation
abstract class BaseRepository<T extends { id: number }> implements Repository<T> {
constructor(
protected readonly db: Database,
protected readonly tableName: string
) {}
async findById(id: number): Promise<T | null> {
const result = await this.db.query<T>(
`SELECT * FROM ${this.tableName} WHERE id = $1`, [id]
);
return result[0] ?? null;
}
async create(data: Omit<T, "id" | "createdAt" | "updatedAt">): Promise<T> {
const keys = Object.keys(data);
const values = Object.values(data);
const placeholders = keys.map((_, i) => `$${i + 1}`).join(", ");
const result = await this.db.query<T>(
`INSERT INTO ${this.tableName} (${keys.join(", ")}) VALUES (${placeholders}) RETURNING *`,
values
);
return result[0];
}
// Other methods...
async findAll(_opts?: FindOptions): Promise<T[]> { return []; }
async update(_id: number, _data: Partial<T>): Promise<T> { return {} as T; }
async delete(_id: number): Promise<void> {}
async count(_where?: Partial<T>): Promise<number> { return 0; }
}
// Specific implementation
class UserRepository extends BaseRepository<User> {
constructor(db: Database) { super(db, "users"); }
async findByEmail(email: string): Promise<User | null> {
const result = await this.db.query<User>(
"SELECT * FROM users WHERE email = $1", [email]
);
return result[0] ?? null;
}
async findActive(): Promise<User[]> {
return this.db.query<User>("SELECT * FROM users WHERE active = true");
}
}
TypeScript design patterns in 2026 are powerful precisely because the type system enforces correct usage at compile time — the Observer pattern above catches wrong event data before runtime, the Repository pattern ensures type-safe database access. Apply patterns to solve real problems, not as pattern-for-its-own-sake over-engineering.
📚 You might also like
🔗 Share this article




✍️ Leave a Comment