Published on

Refactoring with Abstract Factory Pattern in TypeScript

Hello, I've been reading about design patterns for a while, and I always thought that since they are more associated with object-oriented programming, they aren't commonly used in frontend. But I had to refactor some code, and suddenly I realized that the issue I was facing could be solved with the Abstract Factory pattern. I wanted to share it.

The Problem

The issue was that we had a class that had a type property, and in many methods, we were performing different tasks based on the type with if statements:

class Worker {
  constructor(private type: 'client' | 'server' | 'auto' = 'client') {}

  startJob() {
    if (this.type === 'client') this.startClientJob();
    if (this.type === 'server') this.startServerJob();
    if (this.type === 'auto') this.startSomeJob();
  }
}

Each task did a lot of work by itself, which caused the class to become unnecessarily large, unclear, and messy. To solve this problem, I used the Abstract Factory pattern. So, I had a base class where the shared logic was placed:

abstract class BaseWorker {
  // shared methods here
}

To abstract even further, I also defined the main methods in the base class, but with abstract handlers that are defined in the child classes:

abstract class BaseWorker {
  protected abstract jobHandler(): void;

  public async startJob() {
    this.jobHandler();
    // shared logic for starting job
  }
}

Implementing for Each Type

Now, for each type, I defined different classes (ClientWorker, ServerWorker, AutoWorker) that each implemented the jobHandler method, for example:

class ClientWorker extends BaseWorker {
  protected jobHandler() {
    // do client-related logic
  }
}

Of course, we could have made startJob abstract directly and defined it in the child classes, but in my case, that wasn't necessary.

Factory for Creating Workers

Now, we just need to create a factory class that generates each of these classes for us, and we are sure that all the classes will have the startJob method (whether it's in the base class or in the child classes):

class WorkerFactory {
  private static currentWorker: BaseWorker | null = null;

  static createWorker(type, args) {
    switch (type) {
      case 'server':
        WorkerFactory.currentWorker = new ServerWorker();
        return WorkerFactory.currentWorker;
      case 'client':
        const { type, quality, filename } = args;
        WorkerFactory.currentWorker = new ClientWorker();
        return WorkerFactory.currentWorker;
    }
    throw new Error('worker type is not valid');
  }
}

Managing UI State

Finally, since each method impacts the UI somewhere, I created a manager class that not only invokes the worker methods but also updates the UI state simultaneously:

class WorkerManager {
  worker: BaseWorker;
  
  constructor(store) {
    this.store = store;
  }

  init(args) {
    this.worker = WorkerFactory.createWorker(args);
  }

  startJob() {
    this.store.setState({ isLoading: true });
    this.worker.startJob();
    this.store.setState({ isLoading: false, isWorking: true });
  }

  // other methods
}
export const workerManger = new WorkerManager(useWorkerStore)
Note: It's better to inject the store dependancy through constructor and not only importing it.
Note: Since the manager class creates the worker, use singleton (export new WorkerManager()) to prevent bugs.

Conclusion

This way, the logic related to each type went into a separate class, and the state related to the worker was updated in the manager class. So, whenever we face a bug, we can directly go to the relevant class.