Singleton Pattern in TypeScript: Private Constructor
The singleton pattern ensures a class has exactly one instance throughout your application's lifecycle while providing global access to that instance. It's one of the original Gang of Four design...
Key Insights
- Private constructors in TypeScript enforce compile-time restrictions that prevent direct instantiation, making them the foundation of a type-safe singleton implementation.
- Module-level singletons often provide simpler, more testable alternatives to class-based singletons by leveraging JavaScript’s native module caching behavior.
- Singletons introduce hidden dependencies and global state that complicate testing—always provide reset mechanisms and consider interface extraction for mockability.
Introduction to the Singleton Pattern
The singleton pattern ensures a class has exactly one instance throughout your application’s lifecycle while providing global access to that instance. It’s one of the original Gang of Four design patterns, and despite its age, it remains relevant in modern TypeScript applications.
You’ll encounter legitimate singleton use cases in configuration managers that load settings once and share them everywhere, logging services that maintain consistent formatting and output destinations, database connection pools that manage expensive resources, and caching layers that need application-wide consistency.
The pattern solves a real problem: coordinating access to shared resources. But it’s also one of the most misused patterns in software development. Understanding both its implementation and its pitfalls will help you apply it appropriately.
The Role of Private Constructors
TypeScript’s private keyword on a constructor prevents external code from using new to create instances. This compile-time enforcement is the cornerstone of singleton implementation.
class DatabaseConnection {
private constructor() {
// Initialize connection
}
}
// This fails at compile time:
// Error: Constructor of class 'DatabaseConnection' is private
const connection = new DatabaseConnection();
The private constructor creates a controlled entry point. Only code inside the class itself can call new, which means you control exactly when and how instances get created.
Note that TypeScript’s private is a compile-time construct only. At runtime, JavaScript has no concept of private constructors—the restriction exists purely in the type system. If someone bypasses TypeScript or uses any, they can still instantiate the class. For true runtime privacy, you’d need JavaScript’s #private syntax, though this is rarely necessary for singleton implementations.
Implementing a Basic Singleton
The classic singleton combines a private constructor with a static instance property and a public accessor method:
class ConfigurationManager {
private static instance: ConfigurationManager | null = null;
private settings: Map<string, string> = new Map();
private constructor() {
// Load default configuration
this.settings.set('apiUrl', 'https://api.example.com');
this.settings.set('timeout', '5000');
}
public static getInstance(): ConfigurationManager {
if (ConfigurationManager.instance === null) {
ConfigurationManager.instance = new ConfigurationManager();
}
return ConfigurationManager.instance;
}
public get(key: string): string | undefined {
return this.settings.get(key);
}
public set(key: string, value: string): void {
this.settings.set(key, value);
}
}
// Usage
const config = ConfigurationManager.getInstance();
config.set('apiUrl', 'https://staging.example.com');
// Anywhere else in the application
const sameConfig = ConfigurationManager.getInstance();
console.log(sameConfig.get('apiUrl')); // 'https://staging.example.com'
This implementation uses lazy initialization—the instance isn’t created until the first call to getInstance(). This delays resource allocation until actually needed and avoids initialization order issues during application startup.
The null check in getInstance() ensures only one instance ever exists. Subsequent calls return the existing instance rather than creating new ones.
Thread Safety Considerations
JavaScript runs on a single thread, so you might think race conditions aren’t a concern. However, asynchronous operations can still cause problems during singleton initialization.
Consider a singleton that performs async setup:
// Problematic: Race condition during async initialization
class ApiClient {
private static instance: ApiClient | null = null;
private authToken: string = '';
private constructor() {}
public static async getInstance(): Promise<ApiClient> {
if (ApiClient.instance === null) {
ApiClient.instance = new ApiClient();
// If two calls happen before this completes,
// both might try to authenticate
await ApiClient.instance.authenticate();
}
return ApiClient.instance;
}
private async authenticate(): Promise<void> {
// Expensive authentication call
this.authToken = await fetchAuthToken();
}
}
If multiple parts of your application call getInstance() simultaneously during startup, you might trigger multiple authentication requests. Here’s a safer approach:
class ApiClient {
private static instance: ApiClient | null = null;
private static initializationPromise: Promise<ApiClient> | null = null;
private authToken: string = '';
private constructor() {}
public static async getInstance(): Promise<ApiClient> {
if (ApiClient.initializationPromise !== null) {
return ApiClient.initializationPromise;
}
if (ApiClient.instance !== null) {
return ApiClient.instance;
}
ApiClient.initializationPromise = ApiClient.createInstance();
return ApiClient.initializationPromise;
}
private static async createInstance(): Promise<ApiClient> {
const client = new ApiClient();
await client.authenticate();
ApiClient.instance = client;
ApiClient.initializationPromise = null;
return client;
}
private async authenticate(): Promise<void> {
this.authToken = await fetchAuthToken();
}
public getToken(): string {
return this.authToken;
}
}
The initialization promise acts as a lock. All concurrent callers receive the same promise, ensuring authenticate() runs exactly once.
Singleton Variations in TypeScript
Class-based singletons aren’t your only option. ES modules provide natural singleton behavior through their caching mechanism—a module’s code executes once, and subsequent imports receive the cached exports.
// logger.ts - Module-based singleton
class Logger {
private level: 'debug' | 'info' | 'warn' | 'error' = 'info';
setLevel(level: 'debug' | 'info' | 'warn' | 'error'): void {
this.level = level;
}
log(message: string): void {
console.log(`[${this.level.toUpperCase()}] ${message}`);
}
}
// Single instance created on first import
export const logger = new Logger();
// Usage in other files
import { logger } from './logger';
logger.log('Application started');
This approach is simpler and more idiomatic in modern JavaScript. The module system handles instance management automatically.
For cases where you need singleton behavior across multiple classes, a generic factory provides flexibility:
class SingletonFactory {
private static instances = new Map<string, unknown>();
public static getInstance<T>(
key: string,
factory: () => T
): T {
if (!SingletonFactory.instances.has(key)) {
SingletonFactory.instances.set(key, factory());
}
return SingletonFactory.instances.get(key) as T;
}
public static resetInstance(key: string): void {
SingletonFactory.instances.delete(key);
}
}
// Usage
const cache = SingletonFactory.getInstance('cache', () => new Map<string, unknown>());
const anotherCache = SingletonFactory.getInstance('cache', () => new Map<string, unknown>());
console.log(cache === anotherCache); // true
Testing and Dependency Injection Challenges
Singletons create testing headaches. State persists between tests, making isolation difficult. A test that modifies singleton state can cause unrelated tests to fail.
Build testability into your singleton from the start:
interface IConfigService {
get(key: string): string | undefined;
set(key: string, value: string): void;
}
class ConfigService implements IConfigService {
private static instance: ConfigService | null = null;
private settings = new Map<string, string>();
private constructor(initialSettings?: Record<string, string>) {
if (initialSettings) {
Object.entries(initialSettings).forEach(([key, value]) => {
this.settings.set(key, value);
});
}
}
public static getInstance(): ConfigService {
if (ConfigService.instance === null) {
ConfigService.instance = new ConfigService({
apiUrl: 'https://api.example.com'
});
}
return ConfigService.instance;
}
// Test-only method
public static resetInstance(): void {
ConfigService.instance = null;
}
// Test-only method for injecting mock state
public static createTestInstance(
settings: Record<string, string>
): ConfigService {
ConfigService.instance = new ConfigService(settings);
return ConfigService.instance;
}
public get(key: string): string | undefined {
return this.settings.get(key);
}
public set(key: string, value: string): void {
this.settings.set(key, value);
}
}
// In tests
beforeEach(() => {
ConfigService.resetInstance();
});
test('uses custom API URL', () => {
ConfigService.createTestInstance({ apiUrl: 'https://test.example.com' });
const config = ConfigService.getInstance();
expect(config.get('apiUrl')).toBe('https://test.example.com');
});
The interface extraction (IConfigService) enables dependency injection. Code that depends on configuration can accept the interface rather than the concrete singleton:
class UserService {
constructor(private config: IConfigService) {}
async fetchUser(id: string): Promise<User> {
const baseUrl = this.config.get('apiUrl');
// ...
}
}
// Production
const userService = new UserService(ConfigService.getInstance());
// Test
const mockConfig: IConfigService = {
get: jest.fn().mockReturnValue('https://mock.example.com'),
set: jest.fn()
};
const testService = new UserService(mockConfig);
When to Avoid Singletons
Singletons introduce hidden dependencies. When a function calls ConfigService.getInstance() internally, that dependency isn’t visible in the function signature. This makes code harder to understand, test, and refactor.
Avoid singletons when:
- Multiple instances might eventually make sense. Today’s “single database” might become tomorrow’s multi-tenant system.
- The class has no inherent reason to be singular. Not everything that’s currently instantiated once needs singleton enforcement.
- You’re using them purely for convenience. Global access isn’t a good enough reason. Dependency injection provides the same convenience with better testability.
Consider these alternatives:
Dependency injection containers manage instance lifecycles while keeping dependencies explicit. Libraries like InversifyJS or tsyringe handle singleton scoping without manual implementation.
Factory patterns create instances on demand while centralizing creation logic. You control instance creation without enforcing global singularity.
Module exports for stateless utilities don’t need singleton classes at all. A module exporting pure functions achieves the same “single source” goal more simply.
Use singletons when you genuinely need exactly one instance coordinating access to a shared resource—and when that constraint is inherent to the problem domain, not just your current architecture. Database connection pools, hardware interfaces, and application-wide caches are reasonable candidates. Generic services that happen to be instantiated once are not.