Skip to main content

Creational patterns in TypeScript

How to create instances of classes? Easy task, you may think, but it is not always. Creational patterns give us solutions to everyday situations where the creation of objects is not so easy.

Sometimes you are faced with constructing complex objects or need to build them in a specific way. In other situations, you have a bunch of related classes and want to instantiate one of them based on runtime conditions. You may even have problems guaranteeing the uniqueness of an object or, on the contrary, want several almost identical copies.

Picture C Dustin at Unsplash


This article will show how to solve these situations with four creational patterns with examples in TypeScript. Let's dive in!

🎒 Prerequisites

To complete this tutorial, you will need the following:

  • A local development environment for TypeScript

  • Basic knowledge of Object-Oriented Programming

Singleton

Ensures that a class has only one instance around the application.

The problem

Some classes, like a Logger, a DatabaseConnection, or a Configuration file, are widely used throughout the application. In these cases, it is convenient to have a single instance. This way, we avoid passing it down the dependency chain or having multiple instances with potentially different data or configurations.

// ❌ Bad example not using singleton
class Logger {
  constructor(
    private formatter: Formatter = DefaultFormatter,
    private writer: Writer = DefaultWriter
  ) {}
  log(message: string) {
    this.writer.write(this.formatter.format(message));
  }
}
class Application {
  logger = new Logger(new Formatter(), new Writer());

  main() {
    this.logger.log("Hello world!");
    // 🤢 dependency hell, remember to pass the instance down the chain
    const service = new Service(this.logger);
    service.doSomething();
  }
}
class Service {
  constructor(private logger: Logger) {}
  doSomething() {
    this.logger.log("Doing something...");
    const repository = new Repository();
    repository.save(new User());
  }
}
class Repository {
  // 😱 another instance, potentially different from the one in Application
  private logger: Logger = new Logger(new Formatter(), new Writer());

  save(user: User) {
    this.logger.log("Saving user...");
    // ...
  }
}

The solution with the Singleton pattern

Using the singleton pattern, we can avoid this dependency hell and error-prone situations. It is a straightforward pattern, but it is handy.

// ✅ Singleton solution
class Logger {
  private static instance: Logger;
  private formatter: Formatter;
  private writer: Writer;

  constructor(
    formatter: Formatter = DefaultFormatter,
    writer: Writer = DefaultWriter
  ) {
    if (Logger.instance) {
      // return the existing instance
      return Logger.instance;
    }
    // real initialization only happens the first time
    this.formatter = formatter;
    this.writer = writer;
    Logger.instance = this;
  }

  log(message: string) {
    this.writer.write(this.formatter.format(message));
  }
}
class Application {
  logger = new Logger(new Formatter(), new Writer());

  main() {
    this.logger.log("Hello world!");
    // 😏 no need to pass the instance down the chain
    const service = new Service();
    service.doSomething();
  }
}
class Service {
  // 😏 no worries about different configurations
  private logger: Logger = new Logger();

  doSomething() {
    this.logger.log("Doing something...");
    const repository = new Repository();
    repository.save({});
  }
}
class Repository {
  // 😏 no new instance created
  private logger: Logger = new Logger(new Formatter(), new Writer());

  save(user: any) {
    this.logger.log("Saving user...");
    // ...
  }
}

Prototype

Creates a copy (clone) of an existing object with controlled changes (mutations)

The problem

Here we face the opposite situation. We want to create a copy of an object but change some of its properties. For example, have a new product with different sizes and prices. Or we are generating a cancellation record from an existing order.

// ❌ Bad example not using a prototype
class Product {
  constructor(
    public name: string,
    public category: string,
    public composition: string,
    public size: string,
    public price: number
  ) {}
}
const mediumTShirt = new Product("T-shirt", "Clothes", "100% cotton", "M", 10);
// 😱 creating a new instance but similar instance is a painfully and error-prone task
const largeTShirt = new Product("T-shirt", "Clothes", "100! cotton", "L", 12);

The solution with the Prototype pattern

The prototype pattern is an excellent solution for this problem. By cloning an existing object, we can create a new one with the desired changes while ensuring the defaults are correct.

// ✅ Prototype solution
class Product {
  constructor(
    public name: string,
    public category: string,
    public composition: string,
    public size: string,
    public price: number
  ) {}
  // 😏 clone method to create a new instance with some changes
  // could have a more semantic name like `withSizeAndPrice`
  clone(newSize: string, newPrice: number) {
    return new Product(
      this.name,
      this.category,
      this.composition,
      newSize,
      newPrice
    );
  }
}
const mediumTShirt = new Product("T-shirt", "Clothes", "100% cotton", "M", 10);
// 😏 no need to create a new instance, just clone the existing one, and ensure the defaults are correct
const largeTShirt = mediumTShirt.clone("L", 12);

Factory

Creates instances of different classes that implement the same interface (or extend the same base class)

The problem

OOP techniques often end with several classes that implement the same interface or extend from a base class. Those classes are the byproduct of the Open/Closed Principle or the Interface Segregation Principle, both part of SOLID principles. The problem arises when we must choose which class to instantiate at runtime.

For example, we have a LoggerWriter interface with different implementations like ConsoleLogger, FileLogger, and DatabaseLogger. Or we have a User base class with variations like Organizer, Customer, and Participant.

// ❌ Bad example not using a factory
interface LoggerWriter {
  write(message: string): void;
}
class ConsoleWriter implements LoggerWriter {
  write(message: string) {
    console.log(message);
  }
}
class FileWriter implements LoggerWriter {
  write(message: string) {
    // ...
  }
}
class DatabaseWriter implements LoggerWriter {
  write(message: string) {
    // ...
  }
}
class Application {
  main() {
    // 😱 which implementation to use?
    let writer: LoggerWriter;
    // 😱 the logic is exposed, and may have to be repeated in other places
    switch (process.env.LOGGER) {
      case "console":
        writer = new ConsoleWriter();
        break;
      case "file":
        writer = new FileWriter();
        break;
      case "database":
        writer = new DatabaseWriter();
        break;
      default:
        throw new Error("Invalid logger");
    }
    const logger = new Logger(writer);
    logger.log("Hello world!");
  }
}

The solution with the Factory pattern

The Factory Method pattern (factory for short) solves this problem by encapsulating the logic to create the correct instance. It also allows us to change the criteria to choose the implementation without affecting the rest of the code.

Disclaimer: Yes, you should avoid using 🤮 switch statements, but at least having only one is better than having it in multiple places and encourages you to refactor it

// ✅ Factory solution
interface LoggerWriter {
  write(message: string): void;
}
class ConsoleWriter implements LoggerWriter {
  write(message: string) {
    console.log(message);
  }
}
class FileWriter implements LoggerWriter {
  write(message: string) {
    // ...
  }
}
class DatabaseWriter implements LoggerWriter {
  write(message: string) {
    // ...
  }
}
// 😏 factory method encapsulates the logic to create the correct instance
function createLoggerWriter(): LoggerWriter {
  // 😏 if the criteria changes, we only need to change the factory
  switch (process.env.LOGGER) {
    case "console":
      return new ConsoleWriter();
    case "file":
      return new FileWriter();
    case "database":
      return new DatabaseWriter();
    default:
      throw new Error("Invalid logger");
  }
}
class Application {
  main() {
    const writer = createLoggerWriter();
    const logger = new Logger(writer);
    logger.log("Hello world!");
  }
}

Builder

Simplifies, drives, or standardizes the construction of complex objects.

The problem

Ideally, we should design multiple simple classes with a small number of properties. Also, we should be able to use any method of the class right after construction. You can achieve this by asking for mandatory parameters as construction. However, sometimes we need to deal with complex or poorly designed objects that need some rituals before being usable.

For example, an Order class comprises Customer, Product, Payment, and Delivery information. Or a Logger class with a Formatter and a Writer that needs to be appropriately configured before being used.

// ❌ Bad example not using a builder
class Logger {
  private formatter: Formatter | undefined;
  private writer: Writer | undefined;

  setFormatter(formatter: Formatter): void {
    this.formatter = formatter;
  }
  setWriter(writer: Writer): void {
    if (!this.formatter) {
      throw "Need a formatter";
    }
    if (
      this.formatter instanceof JsonFormatter &&
      writer instanceof TextFileWriter
    ) {
      throw "Incompatible formatter for this writer";
    }
    this.writer = writer;
  }

  log(entry: LogEntry) {
    if (!this.writer || !this.formatter) {
      throw new Error("Logger is not configured");
    }
    this.writer.write(this.formatter.format(entry));
  }
}

class Application {
  main() {
    const logger = new Logger();
    logger.setWriter(new TextFileWriter()); // 😱 throws "Need a formatter"
    logger.setFormatter(new JsonFormatter());
    logger.setWriter(new TextFileWriter()); // 😱 throws "Incompatible formatter for this writer"
    logger.log({ message: "Hello world!" });
    // 😱 you must remember to call the methods in the correct order,
    // and do it every time you need a new instance
  }
}

The solution with the Builder pattern

The Builder pattern solves this problem by creating a separate class that encapsulates the logic to construct the correct instance.

// ✅ Builder solution
class LoggerBuilder {
  // 😏 ensures that the client will not need to know too much about the logger
  public static build(formatter: Formatter, writer: Writer): Logger {
    if (
      formatter instanceof JsonFormatter &&
      writer instanceof TextFileWriter
    ) {
      // 😏 detects incompatibility before the logger is created
      throw "Incompatible formatter";
    }
    const logger = new Logger();
    // 😏 ensures correct order
    logger.setFormatter(new JsonFormatter());
    logger.setWriter(new ConsoleWriter());
    return logger;
  }
}

A even better solution with a director

Eventually, some compositions shine as more commonly used. For example, a Logger with a JsonFormatter and a ConsoleWriter is a typical combo. In this case, we can create a LoggerDirector with a catalog of those pre-made combinations.

// * 😏 director is an abstraction on top of the builder
// to give clients what they want without knowing the internals
class LoggerDirector {
  public static buildADefaultLogger(): Logger {
    return LoggerBuilder.build(new SimpleFormatter(), new TextFileWriter());
  }
  public static buildAFancyLogger(): Logger {
    return LoggerBuilder.build(new JsonFormatter(), new ConsoleWriter());
  }
}

🌅 Conclusion

When you go to instantiate objects, you will find yourself in certain situations repeatedly. There are proven solutions to those problems, and they are called creational patterns. In this article, I showed you their implementation in TypeScript. As a cheat sheet, I remind you of the patterns and their use cases.

  • Singleton: to create a single instance of a class.

  • Prototype : to create a modified copy of an existing object.

  • Factory : to create objects of related types.

  • Builder : to create a complex object.

I hope you enjoyed it and learned something new. If you have any questions or suggestions, please leave a comment below.

learn, code, enjoy, repeat

Alberto Basalo



Popular posts from this blog

Fine-tune ESLint rules to write better TypeScript

Writing clean code is a lot easier with the right tools well configured. ESLint is a static analyzer for JavaScript programs. So what does that mean and what can it do for my TypeScript code? First things first, by static it means that you don't need to run or even compile a program to parse it. Analyzing means that it checks your code searching for flaws or bad metrics that can cause problems in the long run. The ESLint is a linter that runs before the compiler. Witch runs before your automated tests and the end-user. Is your first line of defense and should be as integrated with your editor as possible. For the details about integrating ESLint and VSCode, you can read my article to configure basic extensions But ESLint would not be so popular if it was not extensible and configurable . And there is the real power and complexity. In this article, I'll walk you through

10 commandments to naming and writing clean code with TypeScript

A code writer is like any other writer; writes to be read and understood . To do so it must obey certain laws. They are so important that must be followed religiously. Being a good writer is an art. But you can be a better programmer by knowing and applying a set of standards. In this guide, I will show you TypeScript samples for the  10 rules of clean naming . When you're finished, you'll be able to write heavenly code.Let there be code naming conventions 1️⃣ Be Descriptive Add value without being repetitive. Context is key. Prefer clarity over saving space. We are not in the ‘90s’ anymore. // ❌ const width = 5 ; class Image { imageWidth = width ; } // ✅ const imageWidth = 5 ; class Image { width = imageWidth; } 2️⃣ Be Meaningful Use the same word for the same concept. Create a dictionary for business and infrastructure common words. // ❌ getClient () {} readProvider () {} postCustomer () {} // ✅ getClient ()

How to configure VSCode to code better TypeScript

Writing a more readable code is your first goal as a programmer. This initial Visual Studio Code setup makes your job easier. Any good worker must know and customize his tools for the job at hand. With minor tweaks, VSCode is the perfect tool to write TypeScript . There are plenty of guides to configure VSCode . Most of them include a lot of extensions. But almost none starts from the very beginning, with no extension at all. I am an enthusiast of writing clean code , so I do my best to promote and facilitate this goal, and I am sure that this can help you to write better code. In this post, you will learn how to adapt VS Code to write better TypeScript code, even without any extension. And above all, you will get tips to adopt good habits and detect programming vices. 🎒 Prerequisites To complete this tutorial, you will need: A local copy of Visual Studi