← All material

Core JavaScript · 3 of 9

Replacing Static Members and Singletons

static members have been used for singleton state or accessing variables without an instance:

The old way

class FeatureFlag {
  private static enabled = false;

  static enable(): void {
    this.enabled = true;
  }

  static isEnabled(): boolean {
    return this.enabled;
  }
}

FeatureFlag.enable();
FeatureFlag.isEnabled(); // true

A module is already a shared instance

A module's top-level code runs once, the first time it's imported, and every importer shares the same module. There's no need for a class or the static keyword to get "one shared instance" — that's just what a module is.

// feature-flag.js
let enabled = false;

export const enable = () => {
  enabled = true;
};

export const isEnabled = () => {
  return enabled;
};

// usage — every importer shares the same `enabled` flag
import { enable, isEnabled } from "./feature-flag.js";

enable();
isEnabled(); // true

Here enabled is also private by scope, the same way state was private-by-closure in the previous lesson — there's no equivalent of a public static field unless you deliberately export it. If you did want enabled itself readable from outside, you'd export a function that returns it, rather than exporting the mutable let binding directly.

The same goes for singletons

The singleton pattern — a private constructor plus a static getInstance() — exists to guarantee exactly one instance no matter how many places ask for it:

The old way

class ConfigService {
  private static instance: ConfigService;

  private constructor(private readonly settings: Settings) {}

  static getInstance(): ConfigService {
    if (!ConfigService.instance) {
      ConfigService.instance = new ConfigService(loadSettings());
    }
    return ConfigService.instance;
  }

  get(key: string) {
    return this.settings[key];
  }
}

ConfigService.getInstance().get("apiUrl");

The new way

A module already gives you single-instance behavior - a module is fetched, parsed, and evaluated once only. Every import of "./config-service.js" anywhere in the app resolves to the same module.

// config-service.js
const defaults = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
};

let config = { ...defaults };

export const initialise = (state = {}) => {
  config.timeout = state?.timeout || defaults.timeout;
  config.apiUrl = state?.apiUrl || defaults.apiUrl;
};

export const getTimeout = () => {
  return config.timeout;
};



// usage — call once at startup; every importer shares the same `config`
import { initialise, get } from "./config-service.js";

initialise({ timeout: 10000 });
get("apiUrl");  // "https://api.example.com" — untouched keys keep their default

initialise();   // no argument — every key falls back to the defaults

Passing state in keeps the module from reaching out for it at import time: a test can call initialise({ timeout: 0 }) without whatever loadSettings() would have touched. Callers pass only the keys they're changing.

The private constructor, the instance-guard if, and getInstance() all existed to compensate for classes not having module-level scope of their own. Once the module is the scope, that plumbing has nothing left to do.