Design pattern Singleton en JavaScript : classe ES6, module, TypeScript (et l’équivalent PHP)

Le pattern Singleton garantit une instance unique. Trois implémentations JavaScript exécutées (classe avec champ statique privé, constructeur qui renvoie l’instance, module ES), la version TypeScript, l’équivalent PHP, et les cas où il vaut mieux s’en passer.

Design pattern Singleton en Javascript
Réponse rapide

Le Singleton garantit qu’une classe n’a qu’une seule instance et fournit un point d’accès global à celle-ci. En JavaScript moderne, la façon la plus simple est un champ statique privé et une méthode getInstance() : static #instance; static getInstance() { return Database.#instance ??= new Database(); }. Un module ES qui exporte un objet est déjà un singleton : c’est souvent la meilleure réponse.

Le Singleton est probablement le design pattern le plus connu et le plus discuté. Sa promesse est simple : une classe dont il ne peut exister qu’une seule instance, accessible de partout. Une connexion à une base de données, un logger, la configuration de l’application sont les exemples classiques. En JavaScript, il s’écrit en quelques lignes, et le langage offre même une alternative gratuite : le module. Voici les implémentations qui fonctionnent aujourd’hui, avec leurs pièges.

Le Singleton en une phrase

Un Singleton empêche la création de plusieurs instances d’une classe et fournit un point d’accès unique à l’instance existante. Le premier appel crée l’objet, les suivants renvoient le même. On l’utilise quand une ressource doit vraiment être partagée : ouvrir dix connexions à la même base parce que dix modules ont fait new Database() serait un gaspillage, et un journal éclaté en dix fichiers serait inutilisable.

On le déconseille dès qu’il sert seulement à éviter de passer un paramètre : il devient alors une variable globale déguisée, difficile à tester et à remplacer. Nous y revenons en fin d’article.

Implémentation 1 : classe avec champ statique privé

C’est l’écriture moderne recommandée. Le champ #instance est privé et statique : il appartient à la classe, pas aux objets, et personne ne peut le lire ou l’écraser de l’extérieur. getInstance() crée l’instance au premier appel grâce à l’opérateur ??= (« affecte si nul »).

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  : même objet
console.log(b.dsn);     // 'mongodb://prod' : le second dsn a été ignoré

La dernière ligne illustre un piège du pattern : les paramètres du second appel sont perdus en silence. Si votre singleton prend des paramètres, soit ils viennent d’une source unique (la configuration), soit getInstance() lève une erreur quand on lui en passe de différents.

Rien n’empêche encore d’écrire new Database() directement. JavaScript n’a pas de constructeur privé, on le simule avec un jeton connu de la seule 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()

Implémentation 2 : le constructeur renvoie l’instance existante

C’est la version historique de cet article, et elle fonctionne toujours : si un constructeur renvoie explicitement un objet, new renvoie cet objet au lieu du nouveau. L’instance est mémorisée dans une propriété statique.

database-constructeur.js
class Database {
  constructor(data) {
    if (Database.instance) {
      return Database.instance;   // new renvoie l'objet existant
    }
    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' : c'est le même objet que mongo
console.log(mongo === mysql); // true

Elle a l’avantage de laisser la syntaxe new Database() intacte pour le code appelant. Elle a l’inconvénient de surprendre : un new qui ne crée rien va contre l’intuition de qui lit le code, et Database.instance est public, donc modifiable. La version à champ privé est plus explicite.

Implémentation 3 : un module ES est déjà un singleton

Un module JavaScript n’est évalué qu’une fois par application, quel que soit le nombre de fichiers qui l’importent. Exporter une instance suffit donc à obtenir un singleton, sans classe ni 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();   // une seule instance pour toute l'application
app.js
import { logger } from './logger.js';
import config from './config.js';

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

Tout fichier qui importe logger reçoit le même objet. C’est la solution à préférer dans la plupart des projets : elle est lisible, sans magie, et le système de modules garantit l’unicité. Deux limites : l’instance est créée au chargement du module, même si personne ne l’utilise, et le singleton n’est unique que par chemin de fichier (deux copies d’un paquet dans node_modules font deux instances).

Version TypeScript

TypeScript ajoute ce qui manque à JavaScript : un constructeur réellement private, qui interdit new à la compilation.

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');  // Erreur TS2673 : le constructeur est privé

L’équivalent en PHP

La structure est identique : propriété statique, constructeur privé, méthode getInstance(). PHP y ajoute deux verrous utiles, __clone privé et __wakeup qui refuse la désérialisation, pour qu’aucune copie ne puisse apparaître.

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

Quand ne pas utiliser un Singleton

Le pattern est critiqué pour de bonnes raisons, et il faut les connaître avant de l’adopter :

  • Il cache les dépendances. Une fonction qui appelle Database.getInstance() au milieu de son code dépend de la base sans le dire dans sa signature. Passer la connexion en paramètre (injection de dépendances) rend la dépendance visible et remplaçable.
  • Il complique les tests. L’état survit d’un test à l’autre. Si vous gardez un singleton, ajoutez une méthode static reset() réservée aux tests, ou testez à travers l’injection.
  • C’est un état global. Tout ce qui est vrai des variables globales l’est du singleton : couplage, effets à distance, ordre d’initialisation.

La règle pratique : un module qui exporte une instance (implémentation 3) pour la configuration et les utilitaires sans état, l’injection de dépendances pour tout ce qui touche à une ressource externe, et la classe getInstance() quand une bibliothèque impose ce point d’accès unique. Le pattern guard clauses et les classes en JavaScript prolongent ce sujet côté structure du code.

Erreurs fréquentes

Une instance impossible à réinitialiser dans les tests Un singleton garde son état d’un test à l’autre. Prévoyez une méthode static reset() réservée aux tests, ou injectez la dépendance plutôt que d’appeler getInstance() dans le code métier.
Des paramètres ignorés au second appel new Database('mysql') renvoie l’instance créée avec « mongo » : les arguments du second appel sont perdus sans avertissement. Documentez-le, ou levez une erreur si les paramètres diffèrent.
Un singleton par copie du module Deux versions d’un même paquet installées dans node_modules donnent deux modules, donc deux « singletons ». Le module ES n’est unique que pour un chemin de fichier donné.
Du code exécuté à l’import Une instance créée au chargement du module se construit même si personne ne l’utilise. Préférez l’instanciation paresseuse dans getInstance().

JavaScript

Damien Flandrin Développeur web depuis 2010, créateur de Gekkode et d’Email Impact. Chaque article est testé sur un projet réel avant publication. Contact
Newsletter

Les nouveaux tests, tutoriels et projets, par e-mail.

Tests reproductibles, code versionné, résultats datés. Jamais de spam.