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
| Concern | Class Singleton | Module Singleton |
|---|---|---|
| Boilerplate | Higher (static + private ctor) | Minimal |
| Lazy init | Built in via getInstance | Eager unless wrapped in a getter |
| Testability | Harder to reset/mock | Harder to reset/mock |
| Cross-realm safety | Can break across bundles | Tied 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
privateso the compiler forbids straynewcalls. - Use
??=for concise, race-free lazy creation of the cached instance. - Expose an
init/resetpair 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.