The builder pattern allows to easily create complex objects with different configurable properties. I’d argue that there are better alternatives to create class instances in TypeScript. I’ll present a few different approaches to use classes in TypeScript and give my opinions on them.

Let’s start with a basic class:

// 1st example: basic class
class Car {
  private color: string;
  private weight: number;
  private price: number;
  private brand: string;
  private productionYear: number;

  constructor(
    weight: number,
    price: number,
    brand: string,
    productionYear: number,
    color: string = "red"
  ) {
    this.weight = weight;
    this.price = price;
    this.brand = brand;
    this.productionYear = productionYear;
    this.color = color;
  }

  public getWeight() {
    return this.weight;
  }

  public getPrice() {
    return this.price;
  }

  public getBrand() {
    return this.brand;
  }

  public getProductionYear() {
    return this.productionYear;
  }

  public getColor() {
    return this.color;
  }
}

const car = new Car(2_000, 50_000, "Ford", 2013);

This code is a bit lenghty given its simplicity. Let’s make it shorter using parameter properties syntax available in TypeScript.

// 2nd example: class with parameter properties
class Car {
  constructor(
    private weight: number,
    private price: number,
    private brand: string,
    private productionYear: number,
    private color: string = "red"
  ) {}

  public getWeight() {
    return this.weight;
  }

  public getPrice() {
    return this.price;
  }

  public getBrand() {
    return this.brand;
  }

  public getProductionYear() {
    return this.productionYear;
  }

  public getColor() {
    return this.color;
  }
}

const car = new Car(2_000, 50_000, "Ford", 2013);

The next problem that we have is that there are a lot of parameters in the constructor, which is hard to read. Let’s fix that.

// 3rd example: class with all parameters packed into a single object
class Car {
  private color: string;
  private weight: number;
  private price: number;
  private brand: string;
  private productionYear: number;

  constructor({
    color = "red",
    weight,
    price,
    brand,
    productionYear,
  }: {
    color?: string;
    weight: number;
    price: number;
    brand: string;
    productionYear: number;
  }) {
    this.color = color;
    this.weight = weight;
    this.price = price;
    this.brand = brand;
    this.productionYear = productionYear;
  }

  public getWeight() {
    return this.weight;
  }

  public getPrice() {
    return this.price;
  }

  public getBrand() {
    return this.brand;
  }

  public getProductionYear() {
    return this.productionYear;
  }

  public getColor() {
    return this.color;
  }
}

const car = new Car({
  weight: 2_000,
  price: 50_000,
  brand: "Ford",
  productionYear: 2013,
});

The code has become lengthy again, but now the code for creating a new car is very readable. We can now also provide the parameters in any order we want which can be very handy.

// this also works:
const car = new Car({
  brand: "Ford",
  weight: 2_000,
  productionYear: 2013,
  price: 50_000,
});

Let’s now introduce one variation of the builder pattern. The builder pattern typically uses 2 separate classes: a builder class and a base class. The builder class is usually a class with methods for adjusting the properties of the built object and a method for building the object.

// 4th approach: builder class + class with parameter properties
class CarBuilder {
  private color?: string;
  private weight?: number;
  private price?: number;
  private brand?: string;
  private productionYear?: number;

  public setColor(color: string) {
    this.color = color;
    return this;
  }

  public setWeight(weight: number) {
    this.weight = weight;
    return this;
  }

  public setPrice(price: number) {
    this.price = price;
    return this;
  }

  public setBrand(brand: string) {
    this.brand = brand;
    return this;
  }

  public setProductionYear(productionYear: number) {
    this.productionYear = productionYear;
    return this;
  }

  public build() {
    if (this.weight === undefined)
      throw new Error("The weight parameter is required");
    if (this.price === undefined)
      throw new Error("The price parameter is required");
    if (this.brand === undefined)
      throw new Error("The brand parameter is required");
    if (this.productionYear === undefined)
      throw new Error("The productionYear parameter is required");

    return new Car(
      this.weight,
      this.price,
      this.brand,
      this.productionYear,
      this.color
    );
  }
}

// the exact same class from 2nd example:
class Car {
  constructor(
    private weight: number,
    private price: number,
    private brand: string,
    private productionYear: number,
    private color: string = "red"
  ) {}

  public getWeight() {
    return this.weight;
  }

  public getPrice() {
    return this.price;
  }

  public getBrand() {
    return this.brand;
  }

  public getProductionYear() {
    return this.productionYear;
  }

  public getColor() {
    return this.color;
  }
}

const carBuilder = new CarBuilder();
const car = carBuilder
  .setWeight(2_000)
  .setBrand("Ford")
  .setPrice(50_000)
  .setProductionYear(2013)
  .build();

console.log(car.getColor()); // red

The builder pattern typically uses method chaining which you might know from algorithms operating on arrays or strings.

const client = clients
  .filter((client) => client.age >= 18)
  .sort((prev, next) => prev.name.localeCompare(next.name))
  .find((client) => client.country === "Poland");

const result = originalString
  .trim() // Remove leading and trailing whitespaces
  .toLowerCase() // Convert the string to lowercase
  .replace(",", "") // Remove commas
  .substring(0, 5); // Get the first 5 characters of the string

Method chaining

Both Array.prototype.filter and Array.prototype.sort return arrays, allowing for an indefinite chaining of array methods. Similarly, builder setters return an instance of a builder, allowing for an indefinite chaining of builder setters. Setter chaining is optional - the return this statement can be omitted and the builder can be accessed in such manner:

carBuilder.setWeight(2_000);
carBuilder.setBrand("Ford");
carBuilder.setPrice(50_000);
carBuilder.setProductionYear(2013);
const car = carBuilder.build();

Conclusions

The builder pattern might be useful in Java code, but it doesn’t seem to be that useful in TypeScript code - it requires creating an additional class, preferably with additional error checking (which works only in runtime and won’t show any errors during compilation time). I recommend using approaches from 2nd or 3rd examples instead (which one you’d rather use will likely depend on number of constructor parameters).

Extra notes

  • public keyword is optional, I’ve added it to make the examples more understandable for developers with less TypeScript experience.
  • getters can be created using get keyword (they are accessed a bit differently though).