Skip to content
Johann Pardanaud

Modern Builder design pattern in JavaScript

The Builder pattern is one of the best known design patterns among the developers. While it is abundantly used in some languages, like Java, it can be relatively absent in other languages, like JavaScript.

There might be a few reasons this design pattern is not used in some language communities, here are two that come to my mind:

  • The language lacks access modifiers, making the built object exposed to mutability, annihilating the use of the builder.
  • The Builder pattern has a bad reputation inside the community, because it is seen as verbose for little to none advantages.

Why you might want to use the Builder pattern

Imagine, as an example, that we have the following User class in our code:

class User {
  firstName = "(none)"
  middleNames: string[] = []
  lastName = "(none)"
  email = "(none)"
  birthDate = new Date()
}

// usage:

const user = new User()
user.firstName = "John"
user.middleNames = ["Larry"]
user.lastName = "Doe"
user.email = "[email protected]"
user.birthDate = new Date("1990-01-01")
class User {
  firstName = "(none)"
  middleNames = []
  lastName = "(none)"
  email = "(none)"
  birthDate = new Date()
}

// usage:

const user = new User()
user.firstName = "John"
user.middleNames = ["Larry"]
user.lastName = "Doe"
user.email = "[email protected]"
user.birthDate = new Date("1990-01-01")

In its current state, this class is missing a few features:

  • Immutability: instances of the User class can be updated by foreign code, which brings uncertainty about the integrity of the data.
  • Convenience: the API doesn't help much to build a User instance, everything has to be done by hand.
  • Coherence: invalid data can be defined in the fields—e.g. a blank string in the lastName field.

The Builder design pattern can help us bring those features easily.

Bringing immutability to our data

The first step is to bring immutability, there are multiple ways to do that and they all come with pros and cons.

Freezing the object

The Object class has a static method named freeze() which forbids any further modifications on the provided object once it's called. According to the MDN, it has the following behavior:

A frozen object can no longer be changed; freezing an object prevents new properties from being added to it, existing properties from being removed, prevents changing the enumerability, configurability, or writability of existing properties, and prevents the values of existing properties from being changed.

With this method, the builder could froze the User instance before returning it:

class User {
  firstName = "(none)"
}

function buildUser() {
  const user = new User()
  user.firstName = "John"
  return Object.freeze(user)
}

const user = buildUser()

user.firstName = "Jane"
// ❌ fails: no exception thrown, but the property is not updated

console.log(user.firstName)
// ✅ prints: John

However, a major drawback is that anyone can instanciate the User class and fill it with arbitrary values, only the instances returned by the builder are frozen.

TypeScript's member visibility

TypeScript provides keywords to specify the visibility of class members, private is one them. This allows us to restrict the access to our class fields and constructors.

But we also want our class fields to be readable by anyone, to solve that we can add a getter for each private field.

class User {
  private _firstName = "(none)"
  get firstName() {
    return this._firstName
  }

  private constructor() {}
}

const user = new User()
// ❌ TS error: Constructor of class 'User' is private and only accessible
//              within the class declaration. ts(2673)

declare const user: User

console.log(user.firstName)
// ✅ prints: (none)

user.firstName = "John"
// ❌ TS error: Cannot assign to 'firstName' because it is a read-only property. ts(2540)

The class can't be instanciated, the fields can be read but not written, everything works as expected.

However, there are two drawbacks:

  • You need to write your code in TypeScript, or provide type declarations.
  • Once the code is transpiled to JavaScript, nothing forbids the usage of the private members. This is something you can totally choose to ignore—in a private codebase for example—but it's good to know.

Private fields

You can exploit an equivalence of TypeScript's member visibility in pure JavaScript by using private fields, also in conjunction with getters:

class User {
  #firstName = "(none)"
  get firstName() {
    return this.#firstName
  }
}

const user = new User()

console.log(user.firstName)
// ✅ prints: (none)

user.firstName = "John"
// ❌ fails: no exception thrown, but the property is not updated

user.#firstName = "John"
// ❌ throws: Uncaught SyntaxError: reference to undeclared
//            private field or method #firstName

The class can be instanciated but the fields cannot be written, whether you are writing your code in TypeScript or JavaScript.

What to choose?

So, what's the best option among the ones above? My personal choice is a combination of:

That way you have the best of both worlds.

Grant the builder access to the private fields

Now that you have immutable instances, you need to be able to build them by updating their private fields.

The only way to update private fields is to trigger an update from a method inside the impacted class. However, we do not want our User class to provide methods used to update itself… So we need to create a Builder class which has access to the private fields of the User class.

In Java, this is done by using nested classes:

public class User {
    private String firstName = "(none)";
    public String getFirstName() {
        return firstName;
    }

    private User() {} // private constructor

    // declare a nested class `Builder` as a child of the `User` class
    public static class Builder {
        public User create(String firstName) {
            var user = new User();
            user.firstName = firstName; // the nested class can update private fields
            return user;
        }
    }
}

System.out.println(new User.Builder().create("John").getFirstName());
// ✅ prints: John

The nested Builder class can create a User instance and update its private fields, this would not be possible without nesting.

JavaScript doesn't provide such a feature out of the box, however it is possible to emulate this behavior with anonymous classes:

class User {
  #firstName = "(none)"
  get firstName() {
    return this.#firstName
  }

  private constructor() {}

  static Builder = class {
    create(firstName: string) {
      const user = new User()
      user.#firstName = firstName
      return user
    }
  }
}

console.log(new User().firstName)
// ✅ prints: (none)

console.log(new User.Builder().create("John").firstName)
// ✅ prints: John
class User {
  #firstName = "(none)"
  get firstName() {
    return this.#firstName
  }

  static Builder = class {
    create(firstName) {
      const user = new User()
      user.#firstName = firstName
      return user
    }
  }
}

console.log(new User().firstName)
// ✅ prints: (none)

console.log(new User.Builder().create("John").firstName)
// ✅ prints: John

Here, instead of creating the Builder class outside of the User class, we create an anonymous class and assign it to the Builder field (not to be confused with the class name).

Writing the actual builder

The builder has three jobs:

  • validating the values;
  • storing them;
  • use them to build an object.

For each field of the object, we will create a method to validate and store the value. To build the actual object, we will create a build() method.

Let's start with the firstName field:

type BuilderOperation = (user: User) => void

function assert(value: boolean, message: string) {
  if (value === false) {
    throw new Error(message)
  }
}

class User {
  #firstName = "(none)"
  get firstName() {
    return this.#firstName
  }

  private constructor() {}

  static Builder = class {
    #operations: BuilderOperation[] = []

    setFirstName(firstName: string) {
      assert(firstName.length > 0, "must not be empty") // validate the value
      this.#operations.push(user => (user.#firstName = firstName)) // store the value
      return this // return the builder instance to provide method chaining
    }
  }
}

console.log(new User.Builder().setFirstName(""))
// ❌ throws: Uncaught Error: must not be empty

console.log(new User.Builder().setFirstName("John"))
// ✅ validation passed
function assert(value, message) {
  if (value === false) {
    throw new Error(message)
  }
}

class User {
  #firstName = "(none)"
  get firstName() {
    return this.#firstName
  }

  static Builder = class {
    #operations = []

    setFirstName(firstName) {
      assert(firstName.length > 0, "must not be empty") // validate the value
      this.#operations.push(user => (user.#firstName = firstName)) // store the value
      return this // return the builder instance to provide method chaining
    }
  }
}

console.log(new User.Builder().setFirstName(""))
// ❌ throws: Uncaught Error: must not be empty

console.log(new User.Builder().setFirstName("John"))
// ✅ validation passed

We can already see how our builder can help to create valid objects without bloating them with methods. But let's move to another field example where the builder really shines: complex types.

Let's create some methods for the middleNames field:

type BuilderOperation = (user: User) => void

function assert(value: boolean, message: string) {
  if (value === false) {
    throw new Error(message)
  }
}

class User {
  #middleNames: string[] = []
  get middleNames() {
    return this.#middleNames
  }

  private constructor() {}

  static Builder = class {
    #operations: BuilderOperation[] = []

    addMiddleNames(...middleNames: string[]) {
      // validate the values
      middleNames.forEach(val => assert(val.length > 0, "must not be empty"))

      // store the values
      this.#operations.push(user => {
        user.#middleNames = user.#middleNames.concat(middleNames)
      })

      // return the builder instance to provide method chaining
      return this
    }

    removeMiddleNames(...middleNames: string[]) {
      // remove the values
      this.#operations.push(user => {
        user.#middleNames = user.#middleNames.filter(val => {
          // filter out the middle name if it's contained in the provided ones
          return !middleNames.includes(val)
        })
      })

      // return the builder instance to provide method chaining
      return this
    }
  }
}

new User.Builder()
  .addMiddleNames("Jack")
  .addMiddleNames("Larry", "Bruce")
  .removeMiddleNames("Bruce")
// ✅ middle names: ["Jack", "Larry"]
function assert(value, message) {
  if (value === false) {
    throw new Error(message)
  }
}

class User {
  #middleNames = []
  get middleNames() {
    return this.#middleNames
  }

  static Builder = class {
    #operations = []

    addMiddleNames(...middleNames) {
      // validate the values
      middleNames.forEach(val => assert(val.length > 0, "must not be empty"))

      // store the values
      this.#operations.push(user => {
        user.#middleNames = user.#middleNames.concat(middleNames)
      })

      // return the builder instance to provide method chaining
      return this
    }

    removeMiddleNames(...middleNames) {
      // remove the values
      this.#operations.push(user => {
        user.#middleNames = user.#middleNames.filter(val => {
          // filter out the middle name if it's contained in the provided ones
          return !middleNames.includes(val)
        })
      })

      // return the builder instance to provide method chaining
      return this
    }
  }
}

new User.Builder()
  .addMiddleNames("Jack")
  .addMiddleNames("Larry", "Bruce")
  .removeMiddleNames("Bruce")
// ✅ middle names: ["Jack", "Larry"]

As you can see, since we have a complex value, our builder can provide more methods to help the developer manipulate it.

Now let's finish our builder by adding the build() method, all the other fields are also provided for the sake of completeness (only in the expanded code):

type BuilderOperation = (user: User) => void

function assert(value: boolean, message: string) {
  if (value === false) {
    throw new Error(message)
  }
}

class User {
  #firstName = "(none)"
  get firstName() {
    return this.#firstName
  }

  #middleNames: string[] = []
  get middleNames() {
    return this.#middleNames
  }

  #lastName = "(none)"
  get lastName() {
    return this.#lastName
  }

  #email = "(none)"
  get email() {
    return this.#email
  }

  #birthDate = new Date()
  get birthDate() {
    return this.#birthDate
  }

  private constructor() {}

  static Builder = class {
    #operations: BuilderOperation[] = []

    setFirstName(firstName: string) {
      assert(firstName.length > 0, "must not be empty") 
      this.#operations.push(user => (user.#firstName = firstName)) 
      return this 
    }

    addMiddleNames(...middleNames: string[]) {
      
      middleNames.forEach(val => assert(val.length > 0, "must not be empty"))

      
      this.#operations.push(user => {
        user.#middleNames = user.#middleNames.concat(middleNames)
      })

      
      return this
    }

    removeMiddleNames(...middleNames: string[]) {
      
      this.#operations.push(user => {
        user.#middleNames = user.#middleNames.filter(val => {
          
          return !middleNames.includes(val)
        })
      })

      
      return this
    }

    setLastName(lastName: string) {
      assert(lastName.length > 0, "must not be empty") 
      this.#operations.push(user => (user.#lastName = lastName)) 
      return this 
    }

    setEmail(email: string) {
      assert(email.length > 0, "must not be empty") 
      this.#operations.push(user => (user.#email = email)) 
      return this 
    }

    setBirthDate(birthDate: Date) {
      
      assert(
        birthDate.getTime() <= Date.now(),
        "must be lower or equal to the current date",
      )

      
      this.#operations.push(user => {
        const b = birthDate
        user.#birthDate = new Date(b.getFullYear(), b.getMonth(), b.getDate())
      })

      
      return this
    }

    build(): User {
      // create a new user
      const user = new User()

      // apply each operation to the new user
      for (const applyOperation of this.#operations) {
        applyOperation(user)
      }

      // return the user, altered by all the operations
      return user
    }
  }
}

// usage:

const user = new User.Builder()
  .setFirstName("John")
  .addMiddleNames("Larry")
  .setLastName("Doe")
  .setEmail("[email protected]")
  .setBirthDate(new Date("1990-01-01"))
  .build()
function assert(value, message) {
  if (value === false) {
    throw new Error(message)
  }
}

class User {
  #firstName = "(none)"
  get firstName() {
    return this.#firstName
  }

  #middleNames = []
  get middleNames() {
    return this.#middleNames
  }

  #lastName = "(none)"
  get lastName() {
    return this.#lastName
  }

  #email = "(none)"
  get email() {
    return this.#email
  }

  #birthDate = new Date()
  get birthDate() {
    return this.#birthDate
  }

  static Builder = class {
    #operations = []

    setFirstName(firstName) {
      assert(firstName.length > 0, "must not be empty") 
      this.#operations.push(user => (user.#firstName = firstName)) 
      return this 
    }

    addMiddleNames(...middleNames) {
      
      middleNames.forEach(val => assert(val.length > 0, "must not be empty"))

      
      this.#operations.push(user => {
        user.#middleNames = user.#middleNames.concat(middleNames)
      })

      
      return this
    }

    removeMiddleNames(...middleNames) {
      
      this.#operations.push(user => {
        user.#middleNames = user.#middleNames.filter(val => {
          
          return !middleNames.includes(val)
        })
      })

      
      return this
    }

    setLastName(lastName) {
      assert(lastName.length > 0, "must not be empty") 
      this.#operations.push(user => (user.#lastName = lastName)) 
      return this 
    }

    setEmail(email) {
      assert(email.length > 0, "must not be empty") 
      this.#operations.push(user => (user.#email = email)) 
      return this 
    }

    setBirthDate(birthDate) {
      
      assert(
        birthDate.getTime() <= Date.now(),
        "must be lower or equal to the current date",
      )

      
      this.#operations.push(user => {
        const b = birthDate
        user.#birthDate = new Date(b.getFullYear(), b.getMonth(), b.getDate())
      })

      
      return this
    }

    build() {
      // create a new user
      const user = new User()

      // apply each operation to the new user
      for (const applyOperation of this.#operations) {
        applyOperation(user)
      }

      // return the user, altered by all the operations
      return user
    }
  }
}

// usage:

const user = new User.Builder()
  .setFirstName("John")
  .addMiddleNames("Larry")
  .setLastName("Doe")
  .setEmail("[email protected]")
  .setBirthDate(new Date("1990-01-01"))
  .build()

Polish the API

You might find this API a bit verbose an not very JavaScript-like. To improve this you can hide the builder instanciation and the call to the build() method in a function like this one:

type BuilderOperation = (user: User) => void

function assert(value: boolean, message: string) {
  if (value === false) {
    throw new Error(message)
  }
}

class User {
  #firstName = "(none)"
  get firstName() {
    return this.#firstName
  }

  #middleNames: string[] = []
  get middleNames() {
    return this.#middleNames
  }

  #lastName = "(none)"
  get lastName() {
    return this.#lastName
  }

  #email = "(none)"
  get email() {
    return this.#email
  }

  #birthDate = new Date()
  get birthDate() {
    return this.#birthDate
  }

  private constructor() {}

  static Builder = class {
    #operations: BuilderOperation[] = []

    setFirstName(firstName: string) {
      assert(firstName.length > 0, "must not be empty") 
      this.#operations.push(user => (user.#firstName = firstName)) 
      return this 
    }

    addMiddleNames(...middleNames: string[]) {
      
      middleNames.forEach(val => assert(val.length > 0, "must not be empty"))

      
      this.#operations.push(user => {
        user.#middleNames = user.#middleNames.concat(middleNames)
      })

      
      return this
    }

    removeMiddleNames(...middleNames: string[]) {
      
      this.#operations.push(user => {
        user.#middleNames = user.#middleNames.filter(val => {
          
          return !middleNames.includes(val)
        })
      })

      
      return this
    }

    setLastName(lastName: string) {
      assert(lastName.length > 0, "must not be empty") 
      this.#operations.push(user => (user.#lastName = lastName)) 
      return this 
    }

    setEmail(email: string) {
      assert(email.length > 0, "must not be empty") 
      this.#operations.push(user => (user.#email = email)) 
      return this 
    }

    setBirthDate(birthDate: Date) {
      
      assert(
        birthDate.getTime() <= Date.now(),
        "must be lower or equal to the current date",
      )

      
      this.#operations.push(user => {
        const b = birthDate
        user.#birthDate = new Date(b.getFullYear(), b.getMonth(), b.getDate())
      })

      
      return this
    }

    build(): User {
      
      const user = new User()

      
      for (const applyOperation of this.#operations) {
        applyOperation(user)
      }

      
      return user
    }
  }
}

type UserComposer = (builder: typeof User.Builder.prototype) => void

function composeUser(userComposer: UserComposer) {
  const builder = new User.Builder()
  userComposer(builder)
  return builder.build()
}

// usage:

const user = composeUser(user => {
  user
    .setFirstName("John")
    .addMiddleNames("Larry")
    .setLastName("Doe")
    .setEmail("[email protected]")
    .setBirthDate(new Date("1990-01-01"))
})
function assert(value, message) {
  if (value === false) {
    throw new Error(message)
  }
}

class User {
  #firstName = "(none)"
  get firstName() {
    return this.#firstName
  }

  #middleNames = []
  get middleNames() {
    return this.#middleNames
  }

  #lastName = "(none)"
  get lastName() {
    return this.#lastName
  }

  #email = "(none)"
  get email() {
    return this.#email
  }

  #birthDate = new Date()
  get birthDate() {
    return this.#birthDate
  }

  static Builder = class {
    #operations = []

    setFirstName(firstName) {
      assert(firstName.length > 0, "must not be empty") 
      this.#operations.push(user => (user.#firstName = firstName)) 
      return this 
    }

    addMiddleNames(...middleNames) {
      
      middleNames.forEach(val => assert(val.length > 0, "must not be empty"))

      
      this.#operations.push(user => {
        user.#middleNames = user.#middleNames.concat(middleNames)
      })

      
      return this
    }

    removeMiddleNames(...middleNames) {
      
      this.#operations.push(user => {
        user.#middleNames = user.#middleNames.filter(val => {
          
          return !middleNames.includes(val)
        })
      })

      
      return this
    }

    setLastName(lastName) {
      assert(lastName.length > 0, "must not be empty") 
      this.#operations.push(user => (user.#lastName = lastName)) 
      return this 
    }

    setEmail(email) {
      assert(email.length > 0, "must not be empty") 
      this.#operations.push(user => (user.#email = email)) 
      return this 
    }

    setBirthDate(birthDate) {
      
      assert(
        birthDate.getTime() <= Date.now(),
        "must be lower or equal to the current date",
      )

      
      this.#operations.push(user => {
        const b = birthDate
        user.#birthDate = new Date(b.getFullYear(), b.getMonth(), b.getDate())
      })

      
      return this
    }

    build() {
      
      const user = new User()

      
      for (const applyOperation of this.#operations) {
        applyOperation(user)
      }

      
      return user
    }
  }
}

function composeUser(userComposer) {
  const builder = new User.Builder()
  userComposer(builder)
  return builder.build()
}

// usage:

const user = composeUser(user => {
  user
    .setFirstName("John")
    .addMiddleNames("Larry")
    .setLastName("Doe")
    .setEmail("[email protected]")
    .setBirthDate(new Date("1990-01-01"))
})

One last thing

I told you earlier the User class is not a good example, so let's explore a better one: a SQL query builder. We're not going to create our own but we're going to use Knex.js.

Imagine we have an HTTP API to list some books, the URL is https://api.example.com/books. When we query this URL, we return all the books we have in the database, the SQL query is really simple:

select `title`, `author`, `year` from `books`

But our API doesn't bring much here, what about supporting some filters and aggregators? We want to be able to search for a title or an author, and we want to be able to count without returning all the books.

This is something you can do manually by building your own SQL query in a string, but it will become messier each time you add a new filter or a new aggregation. Here's how you can do by using Knex.js as your SQL query builder:

declare const httpRequestParams: URLSearchParams

const knex = require("knex")({
  client: "sqlite3",
  connection: {
    filename: "./mydb.sqlite",
  },
  useNullAsDefault: true,
})

const query = knex.select("title", "author", "year").from("books")

// if a title is provided in the URL parameters, filter only the matching ones
if (httpRequestParams.has("title")) {
  query.orWhere("title", "like", `%${httpRequestParams.get("title")}%`)
}

// if an author is provided in the URL parameters, filter only the matching ones
if (httpRequestParams.has("author")) {
  query.orWhere("author", "like", `%${httpRequestParams.get("author")}%`)
}

// if we only want to count, clear the selection and count on the `id` column
if (httpRequestParams.has("shouldCount")) {
  query.clear("select").count("id")
}

console.log(query.toString())

// examples:

// URL: https://api.example.com/books
// ✅ prints: select `title`, `author`, `year` from `books`

// URL: https://api.example.com/books?title=game
// ✅ prints: select `title`, `author`, `year` from `books` where `title` like '%game%'

// URL: https://api.example.com/books?title=game&author=George
// ✅ prints: select `title`, `author`, `year` from `books` where `title` like '%game%' or `author` like '%George%'

// URL: https://api.example.com/books?title=game&shouldCount=true
// ✅ prints: select count(`id`) from `books` where `title` like '%game%'

This is what the Builder design pattern is meant to do, help the developer build some complex values by providing an extensive API. It should allow to setup the various properties of the value in any order and ensure everything is properly configured to generate a valid value at the end.

I hope I was able to help you understand how valuable this pattern could be, in all languages.