export default new Class() — The merits and demerits of the simplest singleton in TypeScript

export default new Class() — The merits and demerits of the simplest singleton in TypeScript

`export default new Class()` is the simplest singleton pattern in TypeScript. I'll share the merits and drawbacks that became apparent after about a year of operation, along with criteria for deciding when to use it.
2026.07.29

This page has been translated by machine translation. View original

Introduction

When you need a singleton in TypeScript, there is a very simple approach.

my-service.ts
class MyService {
  // ...
}

export default new MyService();

That's all there is to it. No getInstance() like the GoF singleton pattern, and no DI container needed. Because Node.js's module system caches modules, the same instance is shared throughout your entire project.

In the automation system I built, I adopted this pattern for all service classes including SSH connections, Kafka, DB, and loggers. After about a year of operation, the advantages and disadvantages became clear, so I'd like to share them.

Actual Code

All service classes in the project follow this pattern.

remote.ts
class Remote {
  private jumpTunnel?: Client;
  private remoteTunnel?: Client;
  // ... 400+ lines of methods
}

export default new Remote();
date-util.ts
class DateUtil {
  // ... pure utility methods
}

export default new DateUtil();
logger.ts
class Logger {
  // ... Winston-based log output
}

export default new Logger();

Why This Pattern "Works"

Node.js's module system (CommonJS / ESM) only evaluates a module on the first require / import and caches the result.

// app.ts
import Remote from "./remote";  // → new Remote() is executed

// other-file.ts
import Remote from "./remote";  // → the cached instance is returned

In other words, export default new Remote() guarantees a single instance throughout the entire process.

Advantages

1. Overwhelmingly Simple

// This alone creates a singleton
export default new MyService();

// On the consuming side
import MyService from "./my-service";
await MyService.doSomething();

There is absolutely no boilerplate from DI containers or factory patterns.

2. Callers Don't Need to Think About Instance Management

// Commands can be executed without worrying about SSH tunnels
const result = await Remote.execRemote("kubectl get pods");

How Remote is initialized or whether the tunnel is established is not the caller's concern.

3. Naturally Fits Stateful Services

For services that "reuse a single connection" like SSH or DB connections, a singleton is a natural choice. If you don't need multiple connections, there's no need for complex management.

Disadvantages

1. Lazy Initialization is Scattered Throughout

Some services require initialization only when they are first used.

kafka.ts
class Kafka {
  private podName?: string;

  private async checkPodname() {
    if (!this.podName) {
      const podName = (await Pod.getPodUtility())[0] || "";
      this.podName = podName;
    }
  }

  public async execTransfer(config: ConfigTransfer): Promise<void> {
    await this.checkPodname();  // ← checked every time
    // ...
  }
}

Since the Pod name is unknown at the time of export default new Kafka(), an if (!this.podName) check is required at the beginning of each method. This becomes scattered throughout the entire service.

remote.ts
// The same pattern in Remote.ts
public async execRemote(cmd: string): Promise<string> {
  if (!this.remoteTunnel) await this.initRemoteTunnel();  // ← lazy initialization
  // ...
}

2. Cannot Be Swapped Out for Mocks in Tests

// This is not possible
import Remote from "./remote";
// Remote is already instantiated
// There is no way to swap it out for a test mock

While it is possible to mock the entire module with Jest's jest.mock(), resetting the instance's internal state or injecting different configurations per test is difficult.

3. Initialization Order Depends on Import Order

// a.ts
import B from "./b";  // B is initialized first
class A { /* uses B */ }
export default new A();

// b.ts
import A from "./a";  // Circular reference! A is still undefined
class B { /* uses A */ }
export default new B();

When a circular dependency occurs, one of the instances becomes undefined. This bug can only be discovered at runtime.

4. Tied to the Process Lifecycle

Singleton instances live until the process terminates. To release resources during a graceful shutdown, you need to explicitly call a stop() method.

// Cleanup on process exit
process.on("SIGTERM", () => {
  Remote.stop();
  // Kafka.stop();
  // DB.close();
  // ... manually stopping all services
});

A DI container can manage the lifecycle of registered services collectively, but with module-level singletons, manual management is required.

When Should You Use This Pattern?

Good fits:

  • CLI tools or batch processing where the process lifecycle is short
  • A small number of services (10 or fewer)
  • Low need for testing (automation tools, script-based systems)
  • All services operate with the same configuration (no need for per-environment config injection)

Poor fits:

  • Web servers where a different context is needed per request
  • When you want to swap out dependencies for mocks in unit tests
  • When dependencies between services are complex and there is a risk of circular references
  • When you want to switch between multiple configuration profiles (production/staging) within the same process

Alternatives

Exporting the Class + Instantiating on the Consumer Side

export class Remote { /* ... */ }

// On the consuming side
const remote = new Remote(config);

The simplest alternative. Makes it easy to swap out for mocks in tests. However, sharing the instance becomes the responsibility of the caller.

Factory Function

let instance: Remote | null = null;

export function getRemote(config?: RemoteConfig): Remote {
  if (!instance) {
    instance = new Remote(config);
  }
  return instance;
}

// Reset for testing
export function resetRemote(): void {
  instance = null;
}

Consolidates lazy initialization and configuration injection in one place. State can be reset with resetRemote() during testing.

Summary

  • export default new Class() is the simplest singleton pattern in TypeScript
  • Node.js's module cache guarantees a single instance throughout the entire process
  • Advantages: zero boilerplate, simplicity on the caller's side
  • Disadvantages: scattered lazy initialization, difficulty in testing, risk of circular references
  • Well-suited for CLI tools and batch processing, but consider alternatives for applications where testability matters

Share this article