Functional Singletons in TypeScript With Real Use Cases

Functional Singletons in TypeScript With Real Use Cases

Singletons are commonly used in Object Oriented Programming when we want to enforce that there is only ever a single instance of a class. This might be because we are trying to encapsulate some global state between processes.

In this article, I will use the queue from my es-reduxed package. The purpose of this queue is to:

  • Store a list of unprocessed event ids
  • Maintain the correct order of events
  • Ensure an event is only processed once

These assurances must be made because there is no guarantee that the events will be sent from the subscription only once and in order. While the system is processing one event, it may receive several more.

As you can guess, if we were to wind up with two instances of the event queue, we could no longer guarantee order and single processing constraints.

Let's take a look at the queue itself, starting with the types:

export type Queue = {
  enqueue: (id: number) => void;
  registerPromise: (id: number, resolver: PromiseResolver) => void;
};

export type PromiseResolver = (value: Store<any, any>) => void;

So we can see a Queue is just an object with two properties:

  • enqueue: a function taking a number and returning void
  • registerPromise: a function taking a number and a function

The registerPromise function is just associating a resolve function from a Promise with an event id so that the queue can resolve the promise with the given redux state when it finishes processing that event.

Let's take a look at the queue implementation:

/**
 * This queue system uses a recursive loop and a primitive state machine to
 * ensure that events are dispatched to redux in exactly the order they were
 * received.
 */
const startQueue = <T extends EventBase>(
  reduxStore: Store<any, any>,
  eventsRepo: EventsRepo<T>
) => {
  const queue: number[] = [];
  const dedupeSet = new Set<number>();
  const promiseMap = new Map<number, PromiseResolver>();
  let state: 'READY' | 'PROCESSING' = 'READY';

  const processEvent = (event: EventBase) => {
    reduxStore.dispatch(event);
    if (event.id === undefined) {
      throw new Error(`Malformed event is missing id: ${event}`);
    }
    const resolver = promiseMap.get(event.id);
    if (resolver) {
      resolver(reduxStore.getState());
      promiseMap.delete(event.id);
    }
  };

  const processQueue = async () => {
    if (state === 'READY') {
      queue.sort((a, b) => a - b);
      const eventId = queue.shift(); // So we only process if something was in the queue
      if (eventId) {
        state = 'PROCESSING';
        if (queue.length) {
          // More than one event in queue, so do bulk processing
          const lastEventIndex = queue.length - 1; // Save queue length in-case it changes during the await
          const lastEventId = queue[lastEventIndex];
          const events = await eventsRepo.getEventRange(eventId, lastEventId);
          events.forEach(processEvent);
          queue.splice(0, lastEventIndex + 1);
        } else {
          const [event] = await eventsRepo.getEvents(eventId - 1, 1);
          processEvent(event);
        }
        state = 'READY';
        processQueue();
      }
    }
  };

  return {
    enqueue: (id: number | string) => {
      const idCoerced = typeof id === 'string' ? parseInt(id, 10) : id;
      if (!dedupeSet.has(idCoerced)) {
        dedupeSet.add(idCoerced);
        queue.push(idCoerced);
        processQueue();
      } else {
        console.warn(`Out of order event: [${idCoerced}]`);
      }
    },
    registerPromise: (id: number, resolve: PromiseResolver) => {
      promiseMap.set(id, resolve);
    },
  };
};

Okay, let's break this down:

const startQueue = <T extends EventBase>(
  reduxStore: Store<any, any>,
  eventsRepo: EventsRepo<T>
) => {

The first thing to notice here is that we do not export this function, startQueue is available only inside the queue.ts module. This is a critical point we will come back to later.

  const processEvent = (event: EventBase) => {
    reduxStore.dispatch(event);
    if (event.id === undefined) {
      throw new Error(`Malformed event is missing id: ${event}`);
    }
    const resolver = promiseMap.get(event.id);
    if (resolver) {
      resolver(reduxStore.getState());
      promiseMap.delete(event.id);
    }
  };

This function is defined inside startQueue, so it is only available within the startQueue function. This is similar to a private method. However, it has access to all variables included in its closure. In this case, we are making use of promiseMap and reduxStore. This is similar to private properties in a class, but we use closures to make them inaccessible outside this context.

  const processQueue = async () => {
    if (state === 'READY') {
      queue.sort((a, b) => a - b);
      const eventId = queue.shift(); // So we only process if something was in the queue
      if (eventId) {
        state = 'PROCESSING';
        if (queue.length) {
          // More than one event in queue, so do bulk processing
          const lastEventIndex = queue.length - 1; // Save queue length in-case it changes during the await
          const lastEventId = queue[lastEventIndex];
          const events = await eventsRepo.getEventRange(eventId, lastEventId);
          events.forEach(processEvent);
          queue.splice(0, lastEventIndex + 1);
        } else {
          const [event] = await eventsRepo.getEvents(eventId - 1, 1);
          processEvent(event);
        }
        state = 'READY';
        processQueue();
      }
    }
  };

Here we continually process the queue in a recursive loop, as long as there are events remaining and the queue is not already processing events. This ensures we only process events once. Because this function is async (it returns a promise), and because it will always call await before it recursively calls processQueue again, this function will not lock the event loop.

It's also important to realise that there might be calls to enqueue during the await step. This would grow the queue, but not be included in the call to getEventRange, so this is why we save the last event index before we await; otherwise, we could accidentally splice out events we hadn't processed yet.

Now that we understand the queue's "private methods" and properties, let's take a look at the "public methods" in the return statement:

  return {
    enqueue: (id: number | string) => {
      const idCoerced = typeof id === 'string' ? parseInt(id, 10) : id;
      if (!dedupeSet.has(idCoerced)) {
        dedupeSet.add(idCoerced);
        queue.push(idCoerced);
        processQueue();
      } else {
        console.warn(`Out of order event: [${idCoerced}]`);
      }
    },
    registerPromise: (id: number, resolve: PromiseResolver) => {
      promiseMap.set(id, resolve);
    },
  };

Aha! So when we enqueue an event, we call processQueue if it is an event we haven't seen before. Remember the state checks in processQueue? We can safely call this function here because it won't do anything if there is already a process running, and it will eventually get to our enqueued event through the recursive loop.

Meanwhile, registerPromise maps a promise to the event id, which will be used later. In this implementation, we only allow resolving one promise per event processed.

Let's get to the meat of this article, the function that instantiates or retrieves this queue as a singleton:

export const getQueue = (() => {
  let instance: Queue;
  return <T extends EventBase>(
    reduxStore: Store<any, any>,
    eventsRepo: EventsRepo<T>
  ) => {
    instance =
      instance === undefined ? startQueue<T>(reduxStore, eventsRepo) : instance;
    return instance;
  };
})();

First, take note of the fact that this is the only run-time export from queue.ts. You can only retrieve a queue by calling getQueue, but what's actually happening here?

export const getQueue = (() => {
 // Trimmed
})();

Above is an immediately invoked function expression. We are defining a function and then calling it. The result of this function is then assigned to the variable getQueue. Well, getQueue is a function -- we can tell because the first word is a verb -- so this immediately invoked function expression needs to return a function.

(() => {
  let instance: Queue;
  return // Trimmed
})();

Before it returns, we declare a mutable variable called instance of the type Queue but do not define a value for it, which means it will be undefined.

(() => {
  let instance: Queue;
  return <T extends EventBase>(
    reduxStore: Store<any, any>,
    eventsRepo: EventsRepo<T>
  ) => {
    // Trimmed
  };
})();

Aha! So after we declare our instance variable for storing a queue, we define a function for our immediately invoked function expression to return. This function signature should look familiar -- it is identical to startQueue's function signature.

(() => {
  let instance: Queue;
  return <T extends EventBase>(
    reduxStore: Store<any, any>,
    eventsRepo: EventsRepo<T>
  ) => {
    instance =
      instance === undefined ? startQueue<T>(reduxStore, eventsRepo) : instance;
    return instance;
  };
})();

The final step: Inside the function returned to getQueue by our immediately invoked function expression, we will: If instance is undefined, call startQueue, passing in our generic type parameter, reduxStore, and eventsRepo, OR, in the case that it is defined, we simply leave instance as instance. Then we return instance.

This means that instance is stored in the closure scope of getQueue. It is no longer accessible anywhere in javascript except by getQueue itself. We can be confident that getQueue will only ever return a single queue instance. The first time it instantiates the queue, and subsequent calls return it.

You can test this fact by asserting:

expect(getQueue()).to.equal(getQueue());

This will return true because both calls return a reference to the same object!

console.log(getQueue() === getQueue()); // true

Remember object equality in javascript compares references.

There's a massive caveat in this implementation; can you spot it? We only use the parameters the first time getQueue is called! This means that if I try to start one queue for one store and another queue for another store, it will simply ignore the second store and return my queue for the first store.

In this way, this pattern is not memoization. If we were using memoization, we would be able to create a singleton per combination of reduxStore and eventsRepo. However, this makes it harder to enforce a singleton pattern. What defines whether reduxStore and eventsRepo are the same as the last call? Would we compare object equality? Deeply nested properties?

For example, if I were to use a proxy-based memoization implementation such as proxy-memoize then changes to properties of, or child properties of reduxStore and eventsRepo would cause calls to getQueue to return a new queue instance! Uh oh!

I like to think of this pattern as brutal memoize. You get to provide your parameters once, and after that, we ignore them.

Will you be using this functional singleton pattern in your code? Do you have a different approach? Let me know what you think in the comments!

Photo by Michael Dziedzic on Unsplash