Newsletter

Design pattern Singleton in JavaScript: classe, modulo, TypeScript

Il pattern Singleton garantisce un'istanza unica. Tre implementazioni JavaScript eseguite (classe con campo statico privato, costruttore che restituisce l'istanza, modulo ES), la versione TypeScript, l'equivalente PHP e i casi in cui è meglio farne a meno.

Design pattern Singleton in JavaScript: una sola istanza con ES6
Risposta rapida

Il Singleton garantisce che una classe abbia una sola istanza e fornisce un punto di accesso globale a quell'istanza. In JavaScript moderno il modo più semplice è un campo statico privato con un metodo getInstance(): static #instance; static getInstance() { return Database.#instance ??= new Database(); }. Un modulo ES che esporta un oggetto è già un singleton: spesso è la risposta migliore.

Il Singleton è probabilmente il design pattern più conosciuto e più discusso. La sua promessa è semplice: una classe di cui può esistere una sola istanza, accessibile da ovunque. Una connessione a un database, un logger, la configurazione dell’applicazione sono gli esempi classici. In JavaScript si scrive in poche righe, e il linguaggio offre perfino un’alternativa gratuita: il modulo. Ecco le implementazioni che funzionano oggi, con le loro insidie.

Il Singleton in una frase

Un Singleton impedisce la creazione di più istanze di una classe e fornisce un punto di accesso unico all’istanza esistente. La prima chiamata crea l’oggetto; le successive restituiscono lo stesso. Lo si usa quando una risorsa deve davvero essere condivisa: aprire dieci connessioni allo stesso database perché dieci moduli hanno fatto new Database() sarebbe uno spreco, e un log spezzato in dieci file sarebbe inutilizzabile.

Lo si sconsiglia non appena serve soltanto a evitare di passare un parametro: diventa allora una variabile globale travestita, difficile da testare e da sostituire. Ci torniamo a fine articolo.

Implementazione 1: classe con campo statico privato

È la scrittura moderna consigliata. Il campo #instance è privato e statico: appartiene alla classe, non agli oggetti, e nessuno può leggerlo o sovrascriverlo dall’esterno. getInstance() crea l’istanza alla prima chiamata grazie all’operatore ??= («assegna se nullo»).

database.js
class Database {
  static #instance;

  constructor(dsn) {
    this.dsn = dsn;
    this.connectedAt = new Date();
  }

  static getInstance(dsn = 'mongodb://localhost') {
    Database.#instance ??= new Database(dsn);
    return Database.#instance;
  }

  query(sql) {
    return `${this.dsn} > ${sql}`;
  }
}

const a = Database.getInstance('mongodb://prod');
const b = Database.getInstance('mysql://autre');

console.log(a === b);   // true: stesso oggetto
console.log(b.dsn);     // 'mongodb://prod': il secondo dsn è stato ignorato

L’ultima riga mostra un’insidia del pattern: i parametri della seconda chiamata vanno persi in silenzio. Se il tuo singleton prende parametri, o arrivano da una sorgente unica (la configurazione), oppure getInstance() solleva un errore quando gliene passi di diversi.

Nulla impedisce ancora di scrivere new Database() direttamente. JavaScript non ha costruttori privati; se ne simula uno con un token noto solo alla classe:

javascript
const cle = Symbol('Database');

class Database {
  static #instance;

  constructor(jeton) {
    if (jeton !== cle) {
      throw new Error('Utilisez Database.getInstance()');
    }
  }

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

new Database();   // Error: Utilisez Database.getInstance()

Implementazione 2: il costruttore restituisce l’istanza esistente

È la versione storica di questo articolo, e funziona ancora: se un costruttore restituisce esplicitamente un oggetto, new restituisce quell’oggetto invece di quello nuovo. L’istanza viene memorizzata in una proprietà statica.

database-constructeur.js
class Database {
  constructor(data) {
    if (Database.instance) {
      return Database.instance;   // new restituisce l'oggetto esistente
    }
    this._data = data;
    Database.instance = this;
  }

  getData() {
    return this._data;
  }

  setData(data) {
    this._data = data;
  }
}

const mongo = new Database('mongo');
console.log(mongo.getData()); // 'mongo'

const mysql = new Database('mysql');
console.log(mysql.getData()); // 'mongo': è lo stesso oggetto di mongo
console.log(mongo === mysql); // true

Ha il vantaggio di lasciare intatta la sintassi new Database() per il codice chiamante. Ha lo svantaggio di sorprendere: un new che non crea nulla va contro l’intuizione di chi legge il codice, e Database.instance è pubblica, quindi modificabile. La versione con campo privato è più esplicita.

Implementazione 3: un modulo ES è già un singleton

Un modulo JavaScript viene valutato una sola volta per applicazione, qualunque sia il numero di file che lo importano. Esportare un’istanza basta quindi a ottenere un singleton, senza classe né getInstance().

config.js
const config = Object.freeze({
  env: process.env.NODE_ENV ?? 'development',
  apiUrl: 'https://api.example.com',
});

export default config;
logger.js
class Logger {
  #lines = [];
  log(message) {
    this.#lines.push(`${new Date().toISOString()} ${message}`);
  }
  get count() {
    return this.#lines.length;
  }
}

export const logger = new Logger();   // una sola istanza per tutta l'applicazione
app.js
import { logger } from './logger.js';
import config from './config.js';

logger.log(`Démarrage en ${config.env}`);

Ogni file che importa logger riceve lo stesso oggetto. È la soluzione da preferire nella maggior parte dei progetti: è leggibile, senza magia, e il sistema dei moduli garantisce l’unicità. Due limiti: l’istanza viene creata al caricamento del modulo, anche se nessuno la usa, e il singleton è unico soltanto per percorso di file (due copie di uno stesso pacchetto in node_modules fanno due istanze).

Versione TypeScript

TypeScript aggiunge quello che manca a JavaScript: un costruttore davvero private, che vieta new già in compilazione.

database.ts
class Database {
  private static instance: Database | undefined;

  private constructor(private readonly dsn: string) {}

  static getInstance(dsn = 'mongodb://localhost'): Database {
    Database.instance ??= new Database(dsn);
    return Database.instance;
  }

  query(sql: string): string {
    return `${this.dsn} > ${sql}`;
  }
}

const db = Database.getInstance();
// new Database('x');  // Errore TS2673: il costruttore è privato

L’equivalente in PHP

La struttura è identica: proprietà statica, costruttore privato, metodo getInstance(). PHP ci aggiunge due lucchetti utili, __clone privato e __wakeup che rifiuta la deserializzazione, perché non possa comparire nessuna copia.

Database.php
final class Database
{
    private static ?Database $instance = null;

    private function __construct(private readonly string $dsn)
    {
    }

    public static function getInstance(string $dsn = 'mysql:host=localhost'): Database
    {
        return self::$instance ??= new self($dsn);
    }

    public function query(string $sql): string
    {
        return "{$this->dsn} > {$sql}";
    }

    private function __clone()
    {
    }

    public function __wakeup(): void
    {
        throw new LogicException('Un singleton ne se désérialise pas.');
    }
}

$a = Database::getInstance('mysql:host=prod');
$b = Database::getInstance();

var_dump($a === $b);   // bool(true)
echo $a->query('SELECT 1'); // mysql:host=prod > SELECT 1

Quando non usare un Singleton

Il pattern è criticato per buone ragioni, ed è bene conoscerle prima di adottarlo:

  • Nasconde le dipendenze. Una funzione che chiama Database.getInstance() in mezzo al suo codice dipende dal database senza dirlo nella firma. Passare la connessione come parametro (dependency injection) rende la dipendenza visibile e sostituibile.
  • Complica i test. Lo stato sopravvive da un test all’altro. Se tieni un singleton, aggiungi un metodo static reset() riservato ai test, oppure testa attraverso l’injection.
  • È uno stato globale. Tutto quello che vale per le variabili globali vale anche per il singleton: accoppiamento, effetti a distanza, ordine di inizializzazione.

La regola pratica: un modulo che esporta un’istanza (implementazione 3) per la configurazione e le utility senza stato; la dependency injection per tutto ciò che tocca una risorsa esterna; e la classe con getInstance() quando una libreria impone questo punto di accesso unico. Il pattern guard clauses e le classi in JavaScript proseguono il discorso sul versante della struttura del codice.

Errori frequenti

Un'istanza impossibile da reinizializzare nei test Un singleton conserva il suo stato da un test all'altro. Prevedi un metodo static reset() riservato ai test, oppure inietta la dipendenza invece di chiamare getInstance() nel codice applicativo.
Parametri ignorati alla seconda chiamata new Database('mysql') restituisce l'istanza creata con «mongo»: gli argomenti della seconda chiamata vanno persi senza nessun avviso. Documentalo, oppure solleva un errore se i parametri sono diversi.
Un singleton per ogni copia del modulo Due versioni dello stesso pacchetto installate in node_modules danno due moduli, quindi due «singleton». Il modulo ES è unico solo per un dato percorso di file.
Codice eseguito all'import Un'istanza creata al caricamento del modulo si costruisce anche se nessuno la usa. Preferisci l'istanziazione lazy dentro getInstance().

JavaScript

Damien Flandrin Sviluppatore web dal 2010, creatore di Gekkode e di Email Impact. Ogni articolo è testato su un progetto reale prima della pubblicazione. Contatti
Newsletter

I nuovi test, tutorial e progetti, via e-mail.

Test riproducibili, codice versionato, risultati datati. Mai spam.