DEV Community

Braden Napier
Braden Napier

Posted on

Creating Singleton Classes with Typescript and Proxies

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()
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode
  • 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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; // ③
}
Enter fullscreen mode Exit fullscreen mode
  1. We build a Constructor type that will infer the class we pass into the singleton 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.
  2. 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.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 (1)

Collapse
 
jonyo profile image
Jonathan Foote • Edited

Hi, this was a great starting point!

When I tried using it though, the changes done internally like this.somePrivateVar = '...' would not persist.

After a bunch of troubleshooting, this is the solution I came up with that solves it:

function singleton<C extends Constructor>(
  ClassToProxy: C,
  ...args: ConstructorParameters<C>
) {
  let instance: InstanceType<C>;

  const ProxyClass = new Proxy(
    // Have to use an empty object here, because we have to start with something but don't want to initialize it yet
    {},
    {
      get(_target, prop) {
        instance = instance ?? (new ClassToProxy(...args) as typeof instance);
        const value = instance[prop as keyof InstanceType<C>];
        return typeof value === 'function' ? value.bind(instance) : value;
      },
    },
  );

  return ProxyClass as typeof instance;
}
Enter fullscreen mode Exit fullscreen mode

A few changes:

  • a few names - this was just personal preference and I was too lazy to name back for this comment lol...
  • get: could not get Reflect.get(...) working... Well it worked, but as I said when it tried changing things internal to the class those changes did not persist. And I added the bind because without it, again, it was not binding correctly.
    • I did try a few other things before this, like passing instance as the receiver but everything I tried when using Reflect.get just did not work.
    • Also tried using a set() trap, did not work in combo with Reflect.set.
  • First thing passed to new Proxy changed to empty {} - not sure if needed but that was just one of the things I tried when troubleshooting and I kept it.

My concern, I'm not sure if this would open access to private members or not. But it seems to work for our needs... If you have a more elegant solution though I'd be interested!

We were using this:

export someLogger = new CustomLogger();
Enter fullscreen mode Exit fullscreen mode

and using someLogger all throughout the code. I wanted to change it so it did not initiate the logger until it was actually used. Initially I thought to turn it into a wrapper, but that would mean I need to update every single place that used someLogger to be a function call. Instead I used the modified solution above, it allowed me to do a drop in replacement.