In general, it is my opinion that the best way to implement a singleton in Javascript is to just use plain objects. They can easily and precisely provide all the functionality of classes without all the mess. However, this isn't always an option and classes are everywhere in the Typescript world, so lets play.
Consider this a fun experimental way to play with the powerful
Proxy
object in Javascript/Typescript.
I have seen a few articles discussing how one might implement the Singleton Pattern with Typescript.
In most cases, they have involved adding certain static fields to any class you wish to use as a singleton which becomes tedious.
I will not be able to explain the benefits of using Singletons better than the many articles out there already available, here is one that uses the "standard" method that describes all the benefits clearly:
Kudos to him for at least pointing out that in the end, the best option is usually to just use plain objects. This is almost always cleaner!
Standard Methods
Construct / Export on Startup
class MyClassCtor {
}
export const myInstance = new MyClassStor()
But this means that just by importing the file this class will get created (including any initialization built into the constructor).
We may not even want to use the instance at all so we would be potentially creating instances all over our app as we use this method more and more that become essentially useless.
Static Methods
This is what I generally see being used. It isn't terrible but requires that each class have its own code to handle getting instances lazily.
To use the example from the linked article above by @bytefer :
class Singleton {
private static singleton: Singleton; // ①
private constructor() {} // ②
public static getInstance(): Singleton { // ③
if (!Singleton.singleton) {
Singleton.singleton = new Singleton();
}
return Singleton.singleton;
}
}
- This requires that every class we may want to act as a singleton must have at the very least, the code above (which is why he points out later in his article that often plain objects are the way to go).
Proxies
Proxies are very powerful and allow us to handle how a user interacts with any Javascript object that we pass into the Proxy!
There are more handlers to consider with Proxies, so be sure to read about the various handlers.
We also have Proxy.revocable which could be used for more advanced cases.
Say we have a simple class we want to be a singleton:
class Test {
private singletonArgs: ConstructorParameters<typeof Test>;
public check = 'this.check value';
constructor(...args: number[]) {
this.singletonArgs = args;
console.log('Creating Instance with Args: ', this.singletonArgs);
}
public sayHello() {
console.log('Hi! My Args are: ', this.singletonArgs);
}
}
Lets see what a Proxy might provide us to make our singleton classes reusable throughout our code:
// A simple type to represent our class, taken from
// https://www.typescriptlang.org/docs/handbook/mixins.html#how-does-a-mixin-work
type Constructor = new <A extends any[]>(...args: A) => {}; // ①
function singleton<C extends Constructor>(
Ctor: C,
...args: ConstructorParameters<C> // ②
) {
let instance: InstanceType<C>;
const ProxyClass = new Proxy(Ctor, {
get(_target, prop, receiver) {
instance = instance ?? (new Ctor(...args) as typeof instance);
return Reflect.get(instance, prop, receiver);
},
});
return ProxyClass as typeof instance; // ③
}
- We build a
Constructor
type that will infer the class we pass into thesingleton
function.- Since it will only be constructed once, we must pass the args that we want to use when it is used for the first time when we first create the singleton.
- The class will not be initialized until it is actually used by runtime code, but it will never be initialized more than once.
- We use
ConstructorParameters
utility type to infer the args from the classes constructor and allow passing them in at startup.- In most cases a singleton should probably not have any args, but I included it to show it is possible.
- We do a cast here, which I generally frown upon, but for utility and higher-order concepts like Proxies, they allow us to tell Typescript how the returned value actually behaves at runtime.
console.log(`
1. Create Singleton of Test Class
`);
// Generate the singleton version of the Test class
// and pass it its expected args of `number[]`
// (It will not instantiate the class until it is actually used in the codebase)
const myInstance = singleton(Test, 1, 2, 3);
console.log(`
2. The class wont be constructed until it is used
`);
myInstance.sayHello();
// Creating Instance with Args: [ 1, 2, 3 ]
// Hi! My Args are: [ 1, 2, 3 ]
console.log(`
3. And it wont be constructed again if we continue using it
`);
myInstance.sayHello();
// Hi! My Args are: [ 1, 2, 3 ]
console.log(myInstance.check);
// this.check value
Taking it Further
If we really wanted that pesky new
option we could utilize the construct
handler and simply refuse to construct our class if instance
already exists.
I would not recommend adding the pointless complexity, but it illustrates just how flexible Proxy
can be:
function Singleton<C extends Constructor>(
Ctor: C,
...args: ConstructorParameters<C>
) {
let instance: InstanceType<C>;
const ProxyClass: C = new Proxy(Ctor, {
get(_target, prop, receiver) {
const cls = instance ?? new ProxyClass(...args);
return Reflect.get(cls, prop, receiver);
},
construct(_target) {
if (instance) {
return instance;
}
instance = new Ctor(...args) as InstanceType<C>;
return instance;
},
});
return ProxyClass as typeof instance & (new () => typeof instance);
}
const MySingleton = Singleton(Test)
const one = new MySingleton()
const two = new MySingleton();
console.log(one === two); // true
This example has issues as the way it is typed it would think it can access static properties that we have not implemented (more reason not to use this in the real-world ;-))
Top comments (0)