Skip to main content

Singleton — How Far a Module's Single Instance Reaches

This chapter covers how far an ES module is itself a single instance, and when you still need another approach.

The problem this pattern set out to solve

GoF catalogs Singleton as a pattern that guarantees a class has exactly one instance and provides a global means of access to it1.

When several instances each hold their own copy of the same settings or cache, you lose track of which value is the right one. Put it in a global variable instead and anyone can overwrite it. Singleton's idea is to avoid both at once by letting the class itself hold on to creation.

What is easy to miss here is the premise that the languages this shape was born in had no unit called a module. C++ and Smalltalk, which GoF used as examples, did have global variables, but once you want to keep something to one copy while fencing it off from outside writes, the place to put it drifted toward a static member of a class. "Being one" and "being a class" are tied together because of that.

Writing it straightforwardly with classes

The subject here is a store of feature flags. Written the way the pattern describes, you make the constructor private and funnel creation through a single static method.

class FeatureFlagStore {
private static instance: FeatureFlagStore | undefined;
private readonly flags = new Map<string, boolean>();

// Block new from outside and funnel creation through getInstance
private constructor() {}

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

isEnabled(key: string): boolean {
return this.flags.get(key) ?? false;
}

setFlag(key: string, value: boolean): void {
this.flags.set(key, value);
}
}

// Callers can get the same instance from anywhere
function renderCheckout(): string {
return FeatureFlagStore.getInstance().isEnabled('new-checkout') ? 'new' : 'legacy';
}

It works as intended, but there is a cost. Looking at the parameters of renderCheckout tells you nothing about this function depending on feature flags. The dependency never shows up in the signature, so you cannot notice it until you read the body.

Replacing it with language features

The environments TypeScript runs in have something the languages GoF used as examples did not: modules. MDN describes how many times an ES module is evaluated as follows.

Modules are only executed once, even if they have been referenced in multiple <script> tags.2

In other words, a value placed at the top level of a module is a single value no matter how many places import it. The same store can be written like this.

There is a boundary to where "one" holds, though. The Node.js documentation spells out the unit of resolution.

ES modules are resolved and cached as URLs.3

Modules are loaded multiple times if the import specifier used to resolve them has a different query or fragment.3

The condition is one instance as long as it resolves to the same URL within the same runtime. When duplicate dependencies pull in two copies of the same package, or when the runtime itself is split as it is with a Worker, the same file gives you separate instances.

const flags = new Map<string, boolean>();

export function isEnabled(key: string): boolean {
return flags.get(key) ?? false;
}

export function setFlag(key: string, value: boolean): void {
flags.set(key, value);
}

flags is not exported, so nothing outside the module can touch it. The module boundary hands you both of the things GoF tried to get from a static member: being one, and not letting anything touch it freely.

Callers import only the functions they need.

import { isEnabled } from './feature-flags';

export function renderCheckout(): string {
return isEnabled('new-checkout') ? 'new' : 'legacy';
}

Compared with the class version, both private static instance and getInstance are gone. Both exist to keep the instance to one, and within the range shown above the module resolution rules take that role over.

How this series judges it

Verdict: conditional. Write it as a module by default, and pick another approach only when you hit one of the conditions below.

Keeping it at the top level of a module is fine when all of the following hold.

  • One per process is enough, and there is no plan to hold several configurations at once
  • Creation takes no arguments, or can be assembled from values fixed at startup such as environment variables
  • There is no need to swap the implementation

If any of the following applies, stop putting state at the top level of a module and move toward receiving the value as an argument. Whether it becomes a class is a secondary question; what matters is handing creation and passing back to the caller.

  • Each test needs its own independent state
  • You want to switch implementations (swapping storage between memory and an external service, for example)
  • Creation needs arguments passed in from outside, or you want to tear it down explicitly

What would overturn this judgment

Testing needs flip this judgment easily, so it is worth looking at them concretely. Module state carries over between tests. Vitest has a feature that clears the registry, but it comes with a limit.

Resets modules registry by clearing the cache of all modules. This allows modules to be reevaluated when reimported. ... Top-level imports cannot be re-evaluated.4

So even after calling vi.resetModules(), the bindings imported at the top of the file stay as they are. Reading them afresh requires a dynamic import.

import { beforeEach, expect, test, vi } from 'vitest';

beforeEach(() => {
vi.resetModules();
});

test('can set a flag', async () => {
const { isEnabled, setFlag } = await import('./feature-flags');
setFlag('new-checkout', true);
expect(isEnabled('new-checkout')).toBe(true);
});

// If the flag set by the previous test survived, this expectation would not be false
test('a flag set by the previous test does not carry over', async () => {
const { isEnabled } = await import('./feature-flags');
expect(isEnabled('new-checkout')).toBe(false);
});

This does isolate the tests, but you end up writing a dynamic import in every one of them. If swapping state in tests keeps coming up, it reads more naturally as a signal to send the design that put state in a module back for rework.

Note that what this section argues against is putting mutable state at the top level of a module, not putting functions or constants in a module. For read-only constants, swapping them in tests is not a problem.

How this relates to existing articles

The same word "singleton" is used with a different meaning in other articles on this site. They are easy to mix up, so here is the breakdown.

  • Laravel's singleton() — a lifetime setting for whether the DI container creates a new instance on each resolution or reuses one. Compared with GoF's Singleton, where the class guarantees its own uniqueness, the ownership of that responsibility is reversed.
  • Core concepts of PayloadCMS — PayloadCMS calls its Globals "singletons," but there the term refers to a data structure that holds exactly one record, not to a design pattern.

TypeScript's module syntax itself is covered in Modules and tsconfig, and class syntax in Class basics.

Footnotes

  1. Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994). Singleton is catalogued there as a creational pattern. The summary of the intent is this guide's own; we have not checked it against the original text. That the book uses C++ and Smalltalk as its examples was confirmed via a secondary source (source: Design Patterns (Wikipedia)).

  2. Source: JavaScript modules (MDN Web Docs), "Other differences between modules and standard scripts".

  3. Source: Modules: ECMAScript modules (Node.js documentation), "URLs" and "file: URLs" under URLs. 2

  4. Source: Vi (Vitest API documentation), vi.resetModules.