Skip to content
TypeScript ts patterns 4 min read

Singleton Pattern

The Singleton pattern ensures a class has exactly one instance and provides a single, well-known access point to it. It is useful for objects that should be shared across an entire application — a configuration store, a logger, a database connection pool — where creating multiple copies would waste resources or cause inconsistent state. In TypeScript you can express the classic form with a private constructor and a static accessor, but the language also gives you a simpler, idiomatic alternative: the module system itself. This page shows both, plus the pitfalls that make Singletons controversial.

The problem it solves

Some resources are inherently shared. If two parts of your code each create their own logger that writes to the same file, or two database clients open their own connection pools, you get duplicated state, race conditions, and wasted memory. Without a single owner, there is no clear answer to “which instance is the real one?”

// ❌ Two independent loggers — buffers and config drift apart
const a = new Logger({ level: "info" });
const b = new Logger({ level: "debug" });
// Which level is actually in effect? Both? Neither?

The Singleton pattern centralizes ownership so that every consumer reaches the same object.

Classic class-based Singleton

The textbook implementation hides the constructor with private so callers cannot use new, and exposes a static getInstance method that lazily creates and caches the one instance.

class AppConfig {
  private static instance: AppConfig | null = null;
  private readonly settings: Map<string, string>;

  private constructor() {
    this.settings = new Map();
  }

  static getInstance(): AppConfig {
    return (AppConfig.instance ??= new AppConfig());
  }

  get(key: string): string | undefined {
    return this.settings.get(key);
  }

  set(key: string, value: string): void {
    this.settings.set(key, value);
  }
}

const config = AppConfig.getInstance();
config.set("theme", "dark");

// ❌ Error: Constructor of class 'AppConfig' is private.
const rogue = new AppConfig();

The private constructor is what makes this a true Singleton — TypeScript rejects any external new AppConfig() at compile time. The ??= (nullish assignment) operator gives concise lazy initialization: the instance is built on first access and reused thereafter.

The idiomatic TypeScript alternative: modules

ES modules are evaluated once and their exports are cached, so a module-level value is already a Singleton. This is usually the cleaner choice in modern TypeScript because it needs no boilerplate and is trivially typed.

// logger.ts
class Logger {
  private buffer: string[] = [];

  log(message: string): void {
    this.buffer.push(`[${new Date().toISOString()}] ${message}`);
  }

  flush(): readonly string[] {
    return this.buffer;
  }
}

export const logger = new Logger();
// anywhere.ts
import { logger } from "./logger";
logger.log("Started"); // same instance everywhere it is imported

Every importer receives the exact same logger reference. If you later need configuration before construction, expose an init function and a getter instead of a bare instance.

Generic Singleton helper

When several classes need Singleton behaviour, a small generic factory avoids repeating the static plumbing while keeping full type inference.

function createSingleton<T>(factory: () => T): () => T {
  let instance: T | undefined;
  return () => (instance ??= factory());
}

const getDbPool = createSingleton(() => ({
  query: (sql: string) => Promise.resolve([] as unknown[]),
}));

const pool = getDbPool();
const samePool = getDbPool();
console.log(pool === samePool); // true

The helper closes over a private instance variable, so the cached value cannot be tampered with from outside, and T flows through from the factory’s return type.

When to use and pitfalls

ConcernClass SingletonModule Singleton
BoilerplateHigher (static + private ctor)Minimal
Lazy initBuilt in via getInstanceEager unless wrapped in a getter
TestabilityHarder to reset/mockHarder to reset/mock
Cross-realm safetyCan break across bundlesTied to module identity

Singletons are effectively global mutable state. They make unit tests harder because the instance persists between tests, and they hide dependencies. Prefer passing collaborators explicitly via dependency injection when you can.

Reach for a Singleton when a resource is genuinely one-per-process and the alternative would be threading the same object through many layers. Avoid it as a convenience for global access to ordinary data — that quickly becomes an untestable god object.

Best Practices

  • Prefer a module-level export over the class form unless you need lazy initialization or polymorphism.
  • Mark the constructor private so the compiler forbids stray new calls.
  • Use ??= for concise, race-free lazy creation of the cached instance.
  • Expose an init/reset pair when tests need to rebuild the instance between cases.
  • Keep the Singleton’s surface small; do not let it accumulate unrelated global state.
  • Type the shared value precisely so consumers get autocomplete and safety.
  • Consider dependency injection first — it usually gives the same sharing with far better testability.
Last updated June 29, 2026
Was this helpful?