Modules Over Singletons in TypeScript

Published on

In software development, certain challenges present themselves repeatedly. This repetition has paved the way for design patterns, serving as blueprints for common problems. The Singleton pattern is one such blueprint, ensuring only one instance of a class exists. But in TypeScript, the module system already guarantees that every import of a module receives the same instance — a built-in singleton mechanism with none of the ceremony.

Rather than argue that in the abstract, let’s put them head to head, three times: the basic form, the dependency-injected form, and the lazy form. Same requirement each round, both spellings side by side.

Round 1: Basic singleton vs basic module

The requirement: one shared config object.

Singleton:

class Config {
  static readonly instance = new Config();
  private constructor() {}

  readonly apiUrl = process.env.API_URL ?? "https://api.example.com";
}

Config.instance.apiUrl;

Module:

// config.ts
export const apiUrl = process.env.API_URL ?? "https://api.example.com";

Both are eager, both give every consumer the same value. The difference is that the singleton is machinery — a class, a private constructor to forbid the second instance, a static field — while the module is just the value. The private constructor exists to enforce, at runtime, what the module system enforces by construction: nobody else can make one. When the language already provides the guarantee, the pattern is pure overhead.

Round 1 to modules, and it isn’t close.

Round 2: DI for singleton vs DI for module

The requirement: a shared database wrapper, but the connection must be injectable — real one in production, fake in tests.

Singleton with DI — and here the pattern starts fighting itself:

class Database {
  private static instance: Database;

  private constructor(private connection: Connection) {}

  static getInstance(connection?: Connection): Database {
    return (Database.instance ??= new Database(connection!));
  }

  query(sql: string) { /* ... */ }
}

Look at that signature. The first caller must supply a connection; every later caller’s argument is silently ignored. Call order across your codebase now determines which dependency wins, the parameter has to be optional (hence the !), and tests still share the static field — a fake injected in one test leaks into the next unless you add a resetForTesting() escape hatch. Singleton and DI have opposite goals: one hides how the instance is built, the other insists you control it.

Module with DI — export a factory, construct once at your composition root, pass it down:

// database.ts
export function createDatabase(connection: Connection) {
  return {
    query(sql: string) { /* uses connection */ },
  };
}

// main.ts
const db = createDatabase(createConnection());
startServer(db);

“One instance” is no longer enforced by machinery at all — it’s a decision made in one visible place, main.ts. And the test writes itself:

it("queries the fake", () => {
  const db = createDatabase(fakeConnection());
  expect(db.query("SELECT 1")).toEqual([1]);
});

No cache invalidation, no mocking framework, no reset hooks. (A class with a constructor parameter is the same pattern in different clothes — the point is that construction is explicit, not that classes are bad.)

Round 2 to modules, by knockout.

Round 3: Lazy singleton vs lazy module

The requirement: the connection is expensive, so nothing should happen until first use. This is the classic singleton’s home turf — getInstance() defers all work — and it’s the one round where the naive module loses:

// database.ts — anti-pattern: connects at import time
const connection = createConnection();

export function query(sql: string) { /* ... */ }

Import this from anywhere — a type-only helper, a CLI script, a test file — and you’ve opened a database connection as a side effect. Side-effectful imports are one of the most common causes of slow test suites and mysterious “why is my script hanging” moments.

Lazy singleton:

class Database {
  private static instance: Database;
  private connection = createConnection();
  private constructor() {}

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

  query(sql: string) { /* ... */ }
}

Lazy module — same ??=, none of the scaffolding:

// database.ts
let connection: Connection | undefined;

export function query(sql: string) {
  connection ??= createConnection();
  /* ... */
}

Both defer the work until first use. The memoization trick is identical; the singleton just wraps it in a class, a private constructor, and a static field to re-enforce what the module cache already guarantees. Everything the lazy singleton does in fourteen lines, the lazy module does in five.

Round 3: a draw on behavior, modules on ceremony.

Where both lose: when “one instance” is a lie

The module cache guarantees one instance per resolved module file — and the singleton’s guarantee is exactly as strong, because the class is just a value in a module. Two ways the assumption breaks in real projects:

  • Dual package hazard: a package shipping both CJS and ESM builds can get loaded through both entry points in one process — two module caches, two “singletons”. Your instanceof checks fail and your shared state isn’t shared.
  • Duplicate versions: if node_modules contains two versions of a package (or a monorepo resolves the same file through two paths), each resolution gets its own instance — static field and all.

There’s no in-language fix on either side; the guarantee is only as strong as your dependency graph. If a library truly needs process-wide uniqueness, it has to anchor on something outside the module system (globalThis, typically).

Verdict

Round Singleton Module
Basic class + private constructor + static field export const
DI fights the pattern; first-caller-wins factory + composition root
Lazy getInstance() ??= in the accessor
Uniqueness under a messy dependency graph breaks breaks the same way

While Singletons have their place in languages without a module cache, TypeScript isn’t one of them. Use eager modules for plain data, lazy modules for expensive shared state, and promote anything your tests need to fake into a factory called once at the composition root.