A

Classical Patterns in TypeScript

patternstypescriptsingletonfactoryobserverproxydesign-patterns

Classical Patterns in TypeScript

The Gang of Four patterns originated in C++ and Java, but modern JavaScript and TypeScript have evolved the implementations significantly. This article covers four essential patterns—Singleton, Factory, Observer, and Proxy—with idiomatic TypeScript implementations and honest assessments of when to use (or avoid) them.

Singleton Pattern

A Singleton ensures only one instance of a class exists throughout your application. In classical OOP languages, this requires careful implementation with private constructors and static methods. In JavaScript, ES modules give you this behavior for free.

ESM Modules Are Natural Singletons

When a module is first imported, Node.js (or your bundler) executes it once and caches the result. Every subsequent import gets the same cached exports:

// src/database.ts
import { Pool } from 'pg';

// This code runs exactly once, on first import
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

console.log('Database pool created'); // You'll see this once

export const db = {
  query: (text: string, params?: unknown[]) => pool.query(text, params),
  getClient: () => pool.connect(),
};
// src/users.ts
import { db } from './database'; // Uses cached instance

// src/orders.ts
import { db } from './database'; // Same instance - no new pool created

This is the preferred approach for most "singleton-like" needs: database connections, configuration objects, loggers.

When You Actually Need a Class-Based Singleton

Sometimes you need lazy initialization or want to enforce the single-instance rule more explicitly:

// src/analytics.ts
interface AnalyticsConfig {
  apiKey: string;
  endpoint: string;
  batchSize: number;
}

class AnalyticsClient {
  private static instance: AnalyticsClient | null = null;
  private queue: Event[] = [];
  private config: AnalyticsConfig;

  // Private constructor prevents direct instantiation
  private constructor(config: AnalyticsConfig) {
    this.config = config;
    this.startBatchProcessor();
  }

  static getInstance(config?: AnalyticsConfig): AnalyticsClient {
    if (!AnalyticsClient.instance) {
      if (!config) {
        throw new Error('AnalyticsClient must be initialized with config first');
      }
      AnalyticsClient.instance = new AnalyticsClient(config);
    }
    return AnalyticsClient.instance;
  }

  // For testing: allow resetting the singleton
  static resetInstance(): void {
    AnalyticsClient.instance = null;
  }

  track(event: string, properties: Record<string, unknown>): void {
    this.queue.push({ event, properties, timestamp: Date.now() });
  }

  private startBatchProcessor(): void {
    setInterval(() => this.flush(), 5000);
  }

  private async flush(): Promise<void> {
    if (this.queue.length === 0) return;
    const batch = this.queue.splice(0, this.config.batchSize);
    // Send to analytics endpoint
  }
}

// Usage
// In app initialization:
AnalyticsClient.getInstance({
  apiKey: process.env.ANALYTICS_KEY!,
  endpoint: 'https://analytics.example.com',
  batchSize: 100,
});

// Anywhere else:
const analytics = AnalyticsClient.getInstance();
analytics.track('page_view', { path: '/home' });

Why Singletons Are Dangerous

Singletons introduce global state, which creates real problems:

Testing becomes painful:

// ❌ Tests pollute each other
test('tracks events', () => {
  const analytics = AnalyticsClient.getInstance();
  analytics.track('test_event', {});
  // This state persists into the next test!
});

test('another test', () => {
  // Oops, the queue from the previous test is still there
});

Server-side rendering breaks:

// In SSR, all requests share the same Node.js process
// A singleton accumulates state across ALL users
const userPrefs = UserPreferences.getInstance();
userPrefs.setTheme('dark'); // User A sets dark mode

// User B's request runs in the same process
const prefs = UserPreferences.getInstance();
console.log(prefs.getTheme()); // 'dark' - WRONG! User B wanted light mode

Dependency injection is impossible:

// ❌ Can't inject a mock for testing
class UserService {
  private db = Database.getInstance(); // Hardcoded dependency

  async getUser(id: string) {
    return this.db.query('SELECT * FROM users WHERE id = $1', [id]);
  }
}

// ✅ Better: accept dependencies
class UserService {
  constructor(private db: DatabaseClient) {}

  async getUser(id: string) {
    return this.db.query('SELECT * FROM users WHERE id = $1', [id]);
  }
}

When Singletons Are Okay

  • Stateless services: A logger or HTTP client with no per-request state
  • Immutable configuration: Read-only config objects loaded at startup
  • Connection pools: Database pools that are designed to be shared

The key question: Does this singleton store state that varies per request or per test? If yes, reconsider.

Factory Pattern

Factories encapsulate object creation logic, especially when you need to create different types based on runtime conditions. They're particularly useful when the construction logic is complex or when you want to hide implementation details.

Simple Factory Function

The most common form in JavaScript—a function that returns objects:

// src/loggers/factory.ts
interface Logger {
  info(message: string, meta?: Record<string, unknown>): void;
  error(message: string, error?: Error): void;
  debug(message: string, meta?: Record<string, unknown>): void;
}

interface LoggerOptions {
  level?: 'debug' | 'info' | 'warn' | 'error';
  prefix?: string;
}

class ConsoleLogger implements Logger {
  constructor(private options: LoggerOptions) {}

  info(message: string, meta?: Record<string, unknown>): void {
    console.log(`[INFO] ${this.options.prefix || ''} ${message}`, meta || '');
  }

  error(message: string, error?: Error): void {
    console.error(`[ERROR] ${this.options.prefix || ''} ${message}`, error);
  }

  debug(message: string, meta?: Record<string, unknown>): void {
    if (this.options.level === 'debug') {
      console.debug(`[DEBUG] ${this.options.prefix || ''} ${message}`, meta || '');
    }
  }
}

class JsonLogger implements Logger {
  constructor(private options: LoggerOptions) {}

  private log(level: string, message: string, extra?: unknown): void {
    console.log(JSON.stringify({
      timestamp: new Date().toISOString(),
      level,
      prefix: this.options.prefix,
      message,
      ...((typeof extra === 'object' && extra) || {}),
    }));
  }

  info(message: string, meta?: Record<string, unknown>): void {
    this.log('info', message, meta);
  }

  error(message: string, error?: Error): void {
    this.log('error', message, {
      error: error?.message,
      stack: error?.stack,
    });
  }

  debug(message: string, meta?: Record<string, unknown>): void {
    if (this.options.level === 'debug') {
      this.log('debug', message, meta);
    }
  }
}

// The factory function
export function createLogger(options: LoggerOptions = {}): Logger {
  const format = process.env.LOG_FORMAT || 'console';

  if (format === 'json') {
    return new JsonLogger(options);
  }

  return new ConsoleLogger(options);
}
// src/services/user-service.ts
import { createLogger } from '../loggers/factory';

const logger = createLogger({ prefix: '[UserService]', level: 'debug' });

export class UserService {
  async createUser(email: string): Promise<User> {
    logger.info('Creating user', { email });
    // ...
  }
}

Factory with Registry (Plugin Pattern)

For extensible systems where new types can be added at runtime:

// src/payments/factory.ts
interface PaymentGateway {
  charge(amount: number, currency: string): Promise<ChargeResult>;
  refund(chargeId: string, amount?: number): Promise<RefundResult>;
}

type GatewayConstructor = new (config: Record<string, unknown>) => PaymentGateway;

class PaymentGatewayFactory {
  private static registry = new Map<string, GatewayConstructor>();

  // Register new gateway types (plugins can call this)
  static register(name: string, constructor: GatewayConstructor): void {
    PaymentGatewayFactory.registry.set(name, constructor);
  }

  static create(name: string, config: Record<string, unknown>): PaymentGateway {
    const Constructor = PaymentGatewayFactory.registry.get(name);

    if (!Constructor) {
      const available = Array.from(PaymentGatewayFactory.registry.keys());
      throw new Error(
        `Unknown payment gateway: "${name}". Available: ${available.join(', ')}`
      );
    }

    return new Constructor(config);
  }

  static getAvailableGateways(): string[] {
    return Array.from(PaymentGatewayFactory.registry.keys());
  }
}

// Built-in gateways register themselves
// src/payments/stripe.ts
class StripeGateway implements PaymentGateway {
  private stripe: Stripe;

  constructor(config: Record<string, unknown>) {
    this.stripe = new Stripe(config.apiKey as string);
  }

  async charge(amount: number, currency: string): Promise<ChargeResult> {
    const intent = await this.stripe.paymentIntents.create({
      amount,
      currency,
    });
    return { id: intent.id, status: intent.status };
  }

  async refund(chargeId: string, amount?: number): Promise<RefundResult> {
    const refund = await this.stripe.refunds.create({
      payment_intent: chargeId,
      amount,
    });
    return { id: refund.id, status: refund.status };
  }
}

PaymentGatewayFactory.register('stripe', StripeGateway);

// src/payments/paypal.ts
class PayPalGateway implements PaymentGateway {
  // PayPal implementation...
}

PaymentGatewayFactory.register('paypal', PayPalGateway);
// Usage based on configuration
const gateway = PaymentGatewayFactory.create(
  process.env.PAYMENT_GATEWAY || 'stripe',
  {
    apiKey: process.env.PAYMENT_API_KEY,
    webhookSecret: process.env.PAYMENT_WEBHOOK_SECRET,
  }
);

await gateway.charge(1999, 'usd'); // Works with any registered gateway

When you need to create families of related objects:

// src/ui/themes.ts
interface Button {
  render(): string;
}

interface Input {
  render(): string;
}

interface Card {
  render(): string;
}

// Abstract factory interface
interface UIFactory {
  createButton(label: string): Button;
  createInput(placeholder: string): Input;
  createCard(content: string): Card;
}

// Light theme implementation
class LightButton implements Button {
  constructor(private label: string) {}
  render(): string {
    return `<button class="bg-white text-black border">${this.label}</button>`;
  }
}

class LightThemeFactory implements UIFactory {
  createButton(label: string): Button {
    return new LightButton(label);
  }
  createInput(placeholder: string): Input {
    return new LightInput(placeholder);
  }
  createCard(content: string): Card {
    return new LightCard(content);
  }
}

// Dark theme implementation
class DarkButton implements Button {
  constructor(private label: string) {}
  render(): string {
    return `<button class="bg-gray-800 text-white">${this.label}</button>`;
  }
}

class DarkThemeFactory implements UIFactory {
  createButton(label: string): Button {
    return new DarkButton(label);
  }
  // ... other methods
}

// Usage: swap entire theme by changing factory
function renderPage(factory: UIFactory): string {
  const button = factory.createButton('Submit');
  const input = factory.createInput('Enter email');
  const card = factory.createCard('Welcome!');

  return `
    ${card.render()}
    ${input.render()}
    ${button.render()}
  `;
}

Observer / Pub-Sub Pattern

The Observer pattern establishes a one-to-many relationship where multiple observers receive updates when a subject changes. Pub-Sub extends this by adding an event channel between publishers and subscribers, fully decoupling them.

Using the Built-in EventTarget (Browser & Node 15+)

The web platform's EventTarget works in modern Node.js:

// src/events/order-events.ts
interface OrderCreatedDetail {
  orderId: string;
  userId: string;
  total: number;
  items: Array<{ productId: string; quantity: number }>;
}

interface OrderShippedDetail {
  orderId: string;
  trackingNumber: string;
  carrier: string;
}

// Type-safe custom events
class OrderCreatedEvent extends CustomEvent<OrderCreatedDetail> {
  constructor(detail: OrderCreatedDetail) {
    super('order:created', { detail });
  }
}

class OrderShippedEvent extends CustomEvent<OrderShippedDetail> {
  constructor(detail: OrderShippedDetail) {
    super('order:shipped', { detail });
  }
}

// Event bus using EventTarget
class OrderEventBus extends EventTarget {
  emitOrderCreated(detail: OrderCreatedDetail): void {
    this.dispatchEvent(new OrderCreatedEvent(detail));
  }

  emitOrderShipped(detail: OrderShippedDetail): void {
    this.dispatchEvent(new OrderShippedEvent(detail));
  }

  onOrderCreated(handler: (event: OrderCreatedEvent) => void): () => void {
    this.addEventListener('order:created', handler as EventListener);
    return () => this.removeEventListener('order:created', handler as EventListener);
  }

  onOrderShipped(handler: (event: OrderShippedEvent) => void): () => void {
    this.addEventListener('order:shipped', handler as EventListener);
    return () => this.removeEventListener('order:shipped', handler as EventListener);
  }
}

export const orderEvents = new OrderEventBus();
// src/services/notification-service.ts
import { orderEvents } from '../events/order-events';

// Subscribe to events
const unsubscribe = orderEvents.onOrderCreated((event) => {
  const { orderId, userId, total } = event.detail;
  console.log(`Sending confirmation email for order ${orderId}`);
  // Send email...
});

// Later: clean up
unsubscribe();
// src/services/order-service.ts
import { orderEvents } from '../events/order-events';

export async function createOrder(userId: string, items: CartItem[]): Promise<Order> {
  const order = await db.orders.create({ userId, items });

  // Publish event - all subscribers notified
  orderEvents.emitOrderCreated({
    orderId: order.id,
    userId,
    total: order.total,
    items: items.map(i => ({ productId: i.productId, quantity: i.quantity })),
  });

  return order;
}

Node.js EventEmitter

Node's built-in event system with better TypeScript support:

// src/events/typed-emitter.ts
import { EventEmitter } from 'events';

// Type-safe event emitter wrapper
interface AppEvents {
  'user:registered': { userId: string; email: string };
  'user:deleted': { userId: string };
  'payment:completed': { orderId: string; amount: number };
  'payment:failed': { orderId: string; error: string };
}

class TypedEventEmitter<T extends Record<string, unknown>> {
  private emitter = new EventEmitter();

  on<K extends keyof T>(event: K, listener: (data: T[K]) => void): this {
    this.emitter.on(event as string, listener);
    return this;
  }

  once<K extends keyof T>(event: K, listener: (data: T[K]) => void): this {
    this.emitter.once(event as string, listener);
    return this;
  }

  off<K extends keyof T>(event: K, listener: (data: T[K]) => void): this {
    this.emitter.off(event as string, listener);
    return this;
  }

  emit<K extends keyof T>(event: K, data: T[K]): boolean {
    return this.emitter.emit(event as string, data);
  }

  // Get listener count for debugging
  listenerCount<K extends keyof T>(event: K): number {
    return this.emitter.listenerCount(event as string);
  }
}

export const appEvents = new TypedEventEmitter<AppEvents>();
// Usage with full type safety
appEvents.on('user:registered', ({ userId, email }) => {
  // TypeScript knows the shape of the data
  console.log(`Welcome ${email}!`);
});

appEvents.emit('user:registered', { userId: '123', email: '[email protected]' });

// Type error: 'invalid' is not a valid event
appEvents.emit('invalid', {}); // ❌ TypeScript error

RxJS for Complex Event Streams

When you need transformation, filtering, or combining event streams:

// src/events/search-stream.ts
import { Subject, debounceTime, distinctUntilChanged, switchMap, filter } from 'rxjs';
import { fromFetch } from 'rxjs/fetch';

// Create a subject for search input
const searchInput$ = new Subject<string>();

// Build a complex event processing pipeline
const searchResults$ = searchInput$.pipe(
  // Wait 300ms after user stops typing
  debounceTime(300),

  // Don't search if query hasn't changed
  distinctUntilChanged(),

  // Don't search for very short queries
  filter(query => query.length >= 2),

  // Cancel previous request if new one comes in
  switchMap(query =>
    fromFetch(`/api/search?q=${encodeURIComponent(query)}`)
      .pipe(switchMap(response => response.json()))
  )
);

// Subscribe to results
searchResults$.subscribe({
  next: (results) => {
    console.log('Search results:', results);
    renderResults(results);
  },
  error: (err) => {
    console.error('Search failed:', err);
  }
});

// Connect to DOM input
document.getElementById('search')?.addEventListener('input', (e) => {
  searchInput$.next((e.target as HTMLInputElement).value);
});

Gotchas with Observer Pattern

Memory leaks from forgotten subscriptions:

// ❌ Leak: event listener never removed
class Component {
  constructor() {
    window.addEventListener('resize', this.handleResize);
  }

  handleResize = () => {
    // Handle resize
  };

  // Missing cleanup!
}

// ✅ Always clean up
class Component {
  private cleanup: (() => void)[] = [];

  constructor() {
    window.addEventListener('resize', this.handleResize);
    this.cleanup.push(() => window.removeEventListener('resize', this.handleResize));
  }

  destroy(): void {
    this.cleanup.forEach(fn => fn());
  }
}

Async handlers and error handling:

// ❌ Errors in async handlers are swallowed
emitter.on('data', async (data) => {
  await processData(data); // If this throws, the error is lost
});

// ✅ Handle errors explicitly
emitter.on('data', async (data) => {
  try {
    await processData(data);
  } catch (error) {
    console.error('Failed to process data:', error);
    // Report to error tracking service
  }
});

Proxy Pattern

JavaScript's built-in Proxy object lets you intercept and customize operations on objects. This enables validation, logging, lazy loading, and reactive state systems.

Validation Proxy

Enforce constraints on object properties:

// src/utils/validated-object.ts
interface ValidationRule<T> {
  validate: (value: T) => boolean;
  message: string;
}

type ValidationRules<T> = {
  [K in keyof T]?: ValidationRule<T[K]>[];
};

function createValidatedObject<T extends object>(
  target: T,
  rules: ValidationRules<T>
): T {
  return new Proxy(target, {
    set(obj, prop, value) {
      const propRules = rules[prop as keyof T];

      if (propRules) {
        for (const rule of propRules) {
          if (!rule.validate(value)) {
            throw new Error(`Validation failed for "${String(prop)}": ${rule.message}`);
          }
        }
      }

      return Reflect.set(obj, prop, value);
    },
  });
}

// Usage
interface User {
  email: string;
  age: number;
  name: string;
}

const user = createValidatedObject<User>(
  { email: '', age: 0, name: '' },
  {
    email: [
      {
        validate: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
        message: 'Must be a valid email address',
      },
    ],
    age: [
      { validate: (v) => v >= 0, message: 'Age cannot be negative' },
      { validate: (v) => v <= 150, message: 'Age must be realistic' },
    ],
    name: [
      { validate: (v) => v.length >= 1, message: 'Name is required' },
      { validate: (v) => v.length <= 100, message: 'Name too long' },
    ],
  }
);

user.email = '[email protected]'; // ✅ Works
user.email = 'invalid';           // ❌ Throws: "Must be a valid email address"
user.age = -5;                    // ❌ Throws: "Age cannot be negative"

Logging/Debugging Proxy

Track all property access and modifications:

// src/utils/debug-proxy.ts
function createDebugProxy<T extends object>(
  target: T,
  label: string = 'Object'
): T {
  return new Proxy(target, {
    get(obj, prop, receiver) {
      const value = Reflect.get(obj, prop, receiver);
      console.log(`[${label}] GET ${String(prop)} ->`, value);
      return value;
    },

    set(obj, prop, value) {
      console.log(`[${label}] SET ${String(prop)} =`, value);
      return Reflect.set(obj, prop, value);
    },

    deleteProperty(obj, prop) {
      console.log(`[${label}] DELETE ${String(prop)}`);
      return Reflect.deleteProperty(obj, prop);
    },

    has(obj, prop) {
      const result = Reflect.has(obj, prop);
      console.log(`[${label}] HAS ${String(prop)} ->`, result);
      return result;
    },
  });
}

// Usage in development
const config = createDebugProxy(
  { apiUrl: 'https://api.example.com', timeout: 5000 },
  'Config'
);

config.apiUrl;        // Logs: [Config] GET apiUrl -> https://api.example.com
config.timeout = 10000; // Logs: [Config] SET timeout = 10000

Reactive State (How Vue/MobX Work)

The foundation of reactive frameworks—automatic dependency tracking:

// src/reactive/simple-reactive.ts
type EffectFn = () => void;

let activeEffect: EffectFn | null = null;
const targetMap = new WeakMap<object, Map<string | symbol, Set<EffectFn>>>();

function track(target: object, key: string | symbol): void {
  if (!activeEffect) return;

  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  let deps = depsMap.get(key);
  if (!deps) {
    deps = new Set();
    depsMap.set(key, deps);
  }

  deps.add(activeEffect);
}

function trigger(target: object, key: string | symbol): void {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;

  const deps = depsMap.get(key);
  if (deps) {
    deps.forEach(effect => effect());
  }
}

function reactive<T extends object>(target: T): T {
  return new Proxy(target, {
    get(obj, key, receiver) {
      track(obj, key);
      return Reflect.get(obj, key, receiver);
    },
    set(obj, key, value, receiver) {
      const result = Reflect.set(obj, key, value, receiver);
      trigger(obj, key);
      return result;
    },
  });
}

function effect(fn: EffectFn): void {
  activeEffect = fn;
  fn(); // Run immediately to collect dependencies
  activeEffect = null;
}

// Usage
const state = reactive({
  count: 0,
  message: 'Hello',
});

// This effect automatically re-runs when state.count changes
effect(() => {
  console.log(`Count is: ${state.count}`);
});

state.count++;  // Logs: "Count is: 1"
state.count++;  // Logs: "Count is: 2"

// Changing message doesn't trigger the effect (not a dependency)
state.message = 'World'; // No log - effect doesn't read message

Lazy Loading Proxy

Defer expensive operations until actually needed:

// src/utils/lazy.ts
function lazy<T extends object>(factory: () => T): T {
  let instance: T | null = null;

  return new Proxy({} as T, {
    get(_, prop, receiver) {
      if (!instance) {
        console.log('Initializing lazy object...');
        instance = factory();
      }
      return Reflect.get(instance, prop, receiver);
    },

    set(_, prop, value) {
      if (!instance) {
        instance = factory();
      }
      return Reflect.set(instance, prop, value);
    },
  });
}

// Expensive initialization deferred until first access
const heavyService = lazy(() => {
  console.log('Loading heavy dependencies...');
  // Imagine this loads large libraries, establishes connections, etc.
  return {
    process: (data: string) => `Processed: ${data}`,
    status: 'ready',
  };
});

console.log('Service created, but not initialized yet');
// Nothing loaded yet

console.log(heavyService.status);
// Now it logs: "Initializing lazy object..." then "Loading heavy dependencies..."
// Then: "ready"

Proxy Gotchas

Private fields don't work through Proxy:

class Secret {
  #privateValue = 42;

  getValue() {
    return this.#privateValue;
  }
}

const secret = new Secret();
const proxied = new Proxy(secret, {});

secret.getValue();  // ✅ Returns 42
proxied.getValue(); // ❌ Throws: Cannot read private member from an object
                    //    whose class did not declare it

Some built-in objects have internal slots:

const date = new Date();
const proxiedDate = new Proxy(date, {});

proxiedDate.getTime(); // ❌ Throws: this is not a Date object

Performance overhead:

// Proxies add overhead to every property access
// Don't use in hot paths (tight loops, frequently called functions)
const data = new Proxy(largeArray, handler);
for (let i = 0; i < 1000000; i++) {
  data[i]; // Each access goes through the proxy trap - slow!
}

See Also

Last updated: March 23, 2026