To begin, let’s define the singleton design pattern.
The singleton pattern allows an object to be instantiated only once, exposes this single instance to consumers, and controls the instantiation of the single instance.
The singleton is another way of getting access to an object instance without using a constructor, although it’s necessary for the object to be designed as a singleton.
Implementation
A classic example of a singleton is a logger. It’s rarely necessary (and often, it’s a problem) to instantiate multiple loggers in an application. Having a singleton means the initialization site is controlled, and the logger configuration will be consistent across the application – for example, the log level won’t change depending on where in the application we call the logger from.
A simple logger looks something as follows, with a constructor taking logLevel
and transport
, and an isLevelEnabled
private method, which allows us to drop logs that the logger is not configured to keep (for example, when the level is warn
we drop info
messages). The logger finally implements the info
, warn
, and error
methods, which behave as previously described; they only call the relevant transport
method if the level is “enabled” (i.e., “above” what the configured log level is).
The possible logLevel
values that power isLevelEnabled
are stored as a static field on Logger
:
class Logger {
static logLevels = ['info', 'warn', 'error'];
constructor(logLevel = 'info', transport = console) {
if (Logger.#loggerInstance) {
throw new TypeError(
'Logger is not constructable, use getInstance()
instead'
);
}
this.logLevel = logLevel;
this.transport = transport;
}
isLevelEnabled(targetLevel) {
return (
Logger.logLevels.indexOf(targetLevel) >=
Logger.logLevels.indexOf(this.logLevel)
);
}
info(message) {
if (this.isLevelEnabled('info')) {
return this.transport.info(message);
}
}
warn(message) {
if (this.isLevelEnabled('warn')) {
this.transport.warn(message);
}
}
error(message) {
if (this.isLevelEnabled('error')) {
this.transport.error(message);
}
}
}
In order to make Logger
a singleton, we need to implement a getInstance
static method that returns a cached instance. In order to do, this we’ll use a static loggerInstance
on Logger
. getInstance
will check whether Logger.loggerInstance
exists and return it if it does; otherwise, it will create a new Logger
instance, set that as loggerInstance
, and return it:
class Logger {
static loggerInstance = null;
// rest of the class
static getInstance() {
if (!Logger.loggerInstance) {
Logger.loggerInstance = new Logger('warn', console);
}
return Logger.loggerInstance;
}
}
Using this in another module is as simple as calling Logger.getInstance()
. All getInstance
calls will return the same instance of Logger
:
const a = Logger.getInstance();
const b = Logger.getInstance();
console.assert(a === b, 'Logger.getInstance() returns the
same reference');
We’ve implemented a singleton with “lazy” initialization. The initialization occurs when the first getInstance
call is made. In the next section, we’ll see how we might extend our code to have an “eager” initialization of loggerInstance
, where loggerInstance
will be initialized when the Logger
code is evaluated.
Ensuring only one singleton instance is constructed
A characteristic of a singleton is the “single instance” concept. We want to “force” consumers to use the getInstance
method.
In order to do this, we can check for the existence of loggerInstance
when the contructor is called:
class Logger {
// rest of the class
constructor(logLevel = 'info', transport = console) {
if (Logger.loggerInstance) {
throw new TypeError(
'Logger is not constructable, use getInstance()
instead'
);
}
this.logLevel = logLevel;
this.transport = transport;
}
// rest of the class
}
In the case where we call getInstance
(and, therefore, Logger.loggerInstance
is populated), the constructor will now throw an error:
Logger.getInstance();
new Logger('info', console); // new TypeError('Logger is
not constructable, use getInstance() instead');
This behavior is useful to ensure that consumers don’t instantiate their own Logger and they use getInstance
instead. All consumers using getInstance
means the configuration to set up the logger is encapsulated by the Logger
class.
There’s still a gap in the implementation, as constructing new Logger()
before any getInstance()
calls will succeed, as shown in the following example:
new Logger('info', console); // Logger { logLevel: 'info',
transport: ... }
new Logger('info', console); // Logger { logLevel: 'info',
transport: ... }
Logger.getInstance();
new Logger('info', console); // new TypeError('Logger is
not constructable, use getInstance() instead');
In multithreaded languages, our implementation would also have a potential race condition – multiple consumers calling Logger.getInstance()
concurrently could cause multiple instances to exist. However, since popular JavaScript runtimes are single-threaded, we won’t have to worry about such a race condition – getInstance
is a “synchronous” method, so multiple calls to it would be interpreted one after the other. For reference, Node.js, Deno, and the mainstream browsers Chrome, Safari, Edge, and Firefox provide a single-threaded JavaScript runtime.
Singleton with eager initialization
Eager initialization can be useful to ensure that the singleton is ready for use and features, such as disabling the constructor when an instance exists, work for all cases.
We can eager-initialize by setting Logger.loggerInstance
in the Logger
constructor:
class Logger {
// rest of the class unchanged
constructor(logLevel = 'info', transport = console) {
// rest of the constructor unchanged
Logger.loggerInstance = this;
}
}
This approach has the downside of the constructor performing a global state mutation, which isn’t ideal from a “single responsibility principle” standpoint; the constructor now has a side-effect of sorts (mutating global state) beyond its responsibility to set up an object instance.
An alternative way to eager-initialize is by running Logger.getInstance()
in the logger’s module; it’s useful to pair it with an export
default
statement:
export class Logger {
// no changes to the Logger class
}
export default Logger.getInstance();
With the preceding exports added, there are now two ways to access a logger instance. The first is to import Logger
by name and call Logger.getInstance()
:
import { Logger } from './logger.js';
const logger = Logger.getInstance();
logger.warn('testing testing 12'); // testing testing 12
The second way to use the logger is by importing the default export:
import logger from './logger.js';
logger.warn('testing testing 12'); // testing testing 12
Any code now importing Logger
will get a pre-determined singleton instance of the logger.
Use cases
A singleton shines when there should only be one instance of an object in an application – for example, a logger that shouldn’t be set up/torn down on every request.
Since the singleton class controls how it gets instantiated, it’s also a good fit for objects that are tricky to configure (again, a logger, a metrics exporter, and an API client are good examples). The instantiation is completely encapsulated if, like in our example, we “disable” the constructor.
There’s a performance benefit to constraining the application to a single instance of an object in terms of memory footprint.
The major drawbacks of singletons are an effect of their reliance on global state (in our example, the static loggerInstance
). It’s hard to test a singleton, especially in a case where the constructor is “disabled” (like in our example), since our tests will want to always have a single instance of the singleton.
Singletons can also be considered “global state” to some extent, which comes with all its drawbacks. Global state can sometimes be a sign of poor design, and updating/consuming global state is error-prone (e.g., if a consumer is reading state but it is then updated and not read again).
Improvements with the “class singleton” pattern
With our singleton logger implementation, it’s possible to modify the internal state of the singleton from outside of it. This is nothing specific to our singleton; it’s the nature of JavaScript. By default, its fields and methods are public.
However, this is a bigger issue in our singleton scenario, since a consumer could reset loggerInstance
using a statement such as Logger.loggerInstance = null
or delete
Logger.loggerInstance
. See the following example:
const logger = Logger.getInstance();
Logger.loggerInstance = null;
const logger = new Logger('info', console); // should throw but creates a new instance
In order to stop consumers from modifying the loggerInstance
static field, we can make it a private field. Private fields in JavaScript are part of the ECMAScript 2023 specification (the 13th ECMAScript edition).
To define a private field, we use the #
prefix for the field name – in this case, loggerInstance
becomes #loggerInstance
. The isLevelEnabled
method becomes #isLevelEnabled
, and we also declare logLevel
and transport
as #logLevel
and #
transport
, respectively:
export class Logger {
// other static fields are unchanged
static #loggerInstance = null;
#logLevel;
#transport;
constructor(logLevel = 'info', transport = console) {
if (Logger.#loggerInstance) {
throw new TypeError(
'Logger is not constructable, use getInstance()
instead'
);
}
this.#logLevel = logLevel;
this.#transport = transport;
}
#isLevelEnabled(targetLevel) {
// implementation unchanged
}
info(message) {
if (this.#isLevelEnabled('info')) {
return this.#transport.info(message);
}
}
warn(message) {
if (this.#isLevelEnabled('warn')) {
this.#transport.warn(message);
}
}
error(message) {
if (this.#isLevelEnabled('error')) {
this.#transport.error(message);
}
}
getInstance() {
if (!Logger.#loggerInstance) {
Logger.#loggerInstance = new Logger('warn', console);
}
return Logger.#loggerInstance;
}
}
It’s not possible to delete loggerInstace
or set it to null
, since attempting to access Logger.#loggerInstance
is a syntax error:
Logger.#loggerInstance = null;
^
SyntaxError: Private field '#loggerInstance' must be
declared in an enclosing class
Another useful technique is to disallow modification of fields on an object. In order to disallow modification, we can use Object.freeze
to freeze the instance once it’s created.
class Logger {
// no changes to the logger class
}
export default Object.freeze(new Logger('warn', console));
Now, when someone attempts to change a field on the Logger
instance, they’ll get TypeError
:
import logger from './logger.js';
logger.transport = {}; // new TypeError('Cannot add
property transport, object is not extensible')
We’ve now refactored our singleton implementation to disallow external modifications to it by using private fields and Object.freeze
. Next, we’ll see how to use EcmaScript (ES) modules to deliver singleton functionality.
A singleton without class fields using ES module behavior
The JavaScript module system has the following caching behavior – if a module is loaded, any further imports of the module’s exports will be cached instances of exports.
Therefore, it’s possible to create a singleton as follows in JavaScript.
class MySingleton {
constructor(value) {
this.value = value;
}
}
export default new MySingleton('my-value');
Multiple imports of the default export will result in only one existing instance of the MySingleton
object. Furthermore, if we don’t export the class, then the constructor doesn’t need to be “protected.”
As the following snippet with dynamic imports shows, both import('./my-singleton.js')
result in the same object. They both return the same object because the output of the import
for a given module is a singleton:
await Promise.all([
import('./my-singleton.js'),
import('./my-singleton.js'),
]).then(([import1, import2]) => {
console.assert(
import1.default.value === 'my-value' &&
import2.default.value === 'my-value',
'instance variable is equal'
);
console.assert(
import1.default === import2.default,
'multiple imports of a module yield the same default
object value, a single MySingleton instance'
);
console.assert(import1 === import2, 'import objects are a
single reference');
});
For our logger, this means we could implement an eager-initialized singleton in JavaScript without any of the heavy-handed guarding of the constructor or even a getInstance
method. Note the use of logLevel
and isLevelEnabled
as a public instance property and a public method, respectively (since it might be useful to have access to them from a consumer). In the meantime, #transport
remains private, and we’ve dropped loggerInstance
and getInstance
. We’ve kept Object.freeze()
, which means that even though logLevel
is readable from a consumer, it’s not available to modify:
class Logger {
static logLevels = ['info', 'warn', 'error'];
#transport;
constructor(logLevel = 'info', transport = console) {
this.logLevel = logLevel;
this.#transport = transport;
}
isLevelEnabled(targetLevel) {
return (
Logger.logLevels.indexOf(targetLevel) >=
Logger.logLevels.indexOf(this.logLevel)
);
}
info(message) {
if (this.isLevelEnabled('info')) {
return this.#transport.info(message);
}
}
warn(message) {
if (this.isLevelEnabled('warn')) {
this.#transport.warn(message);
}
}
error(message) {
if (this.isLevelEnabled('error')) {
this.#transport.error(message);
}
}
}
export default Object.freeze(new Logger('warn', console));
In this part of the chapter, we learned how to implement the singleton pattern with a class that exposes a getInstance()
method, as well as the difference between the eager and lazy initialization of a singleton. We’ve covered some JavaScript features, such as private class fields and Object.freeze
, which can be useful when implementing the singleton pattern. Finally, we explored how JavaScript/ECMAScript modules have singleton-like behavior and can be relied upon to provide this behavior for a class instance.
In the next section, we’ll explore the final creational design pattern covered in this chapter – the factory design pattern.