Marcell Ciszek Druzynski

Builder Pattern

A deep dive into the Builder pattern. We will explore how to implement the Builder pattern in Typescript. We will start with an example of a simple class that requires multiple parameters to be constructed, and then show how the Builder pattern can be used to simplify its creation.

May 24, 2023

The Builder pattern is a design pattern used to create objects with complex constructors. It allows for the construction of objects step by step, with each step returning a modified object until the final product is achieved. This pattern is particularly useful when dealing with objects that require a large number of parameters, or when we want to enforce a certain order of operations during object construction.

In this blog post, we will explore how to implement the Builder pattern in Typescript. We will start with an example of a simple class that requires multiple parameters to be constructed, and then show how the Builder pattern can be used to simplify its creation.

Creating a user

Let's say we want to create a class that can create user objects with useful properties like:

  • name
  • age
  • phone
  • address
  • email
1class User {
2 public name: string;
3 public age: number | null;
4 public phone: string | null;
5 public address: string | null;
6 public email: string | null;
7 constructor(
8 name: string,
9 age: number | null,
10 phone: string | null,
11 address: string | null,
12 email: string | null,
13 ) {
14 this.name = name;
15 this.age = age;
16 this.phone = phone;
17 this.address = address;
18 this.email = email;
19 }
20}
21const user = new User("John", 30, null, null, null);
1class User {
2 public name: string;
3 public age: number | null;
4 public phone: string | null;
5 public address: string | null;
6 public email: string | null;
7 constructor(
8 name: string,
9 age: number | null,
10 phone: string | null,
11 address: string | null,
12 email: string | null,
13 ) {
14 this.name = name;
15 this.age = age;
16 this.phone = phone;
17 this.address = address;
18 this.email = email;
19 }
20}
21const user = new User("John", 30, null, null, null);

The problem with this approach is that it requires passing in null values for properties that are not needed for a specific instance of the User class. This can lead to code that is harder to read and maintain, as it may not be clear which properties are required and which are optional for each instance of the class.

A better approach would be to make the parameters default to null then we would not have to implicitly pass inn the null values when creating new User objects.

1class User {
2 public name: string;
3 public age: number | null;
4 public phone: string | null;
5 public address: string | null;
6 public email: string | null;
7 constructor(
8 name: string,
9 age: number | null = null,
10 phone: string | null = null,
11 address: string | null = null,
12 email: string | null = null,
13 ) {
14 this.name = name;
15 this.age = age;
16 this.phone = phone;
17 this.address = address;
18 this.email = email;
19 }
20}
21
22const user = new User("Bob");
1class User {
2 public name: string;
3 public age: number | null;
4 public phone: string | null;
5 public address: string | null;
6 public email: string | null;
7 constructor(
8 name: string,
9 age: number | null = null,
10 phone: string | null = null,
11 address: string | null = null,
12 email: string | null = null,
13 ) {
14 this.name = name;
15 this.age = age;
16 this.phone = phone;
17 this.address = address;
18 this.email = email;
19 }
20}
21
22const user = new User("Bob");

With this approach we would not have to illicitly pass in null for all the values we do not need for our user. But still it is not the best approach. Let's see how we can construct our user object with the Builder pattern.

Let's start with our User class. This will be the class that will be created under the hood, but the client will not directly talk to the User. Instead we will have a UserBuilder class that will construct our User objects.

1class User {
2 public name: string;
3 public age?: number;
4 public phone?: string;
5 public address?: string;
6 public email?: string;
7 constructor(name: string) {
8 this.name = name;
9 }
10}
11
12class UserBuilder {
13 private user: User;
14 constructor(name: string) {
15 this.user = new User(name);
16 }
17
18 setAge(age: number): UserBuilder {
19 this.user.age = age;
20 return this;
21 }
22 setPhone(phone: string): UserBuilder {
23 this.user.phone = phone;
24 return this;
25 }
26
27 setAddress(address: string): UserBuilder {
28 this.user.address = address;
29 return this;
30 }
31
32 setEmail(email: string): UserBuilder {
33 this.user.email = email;
34 return this;
35 }
36
37 build(): Readonly<User> {
38 return Object.freeze(this.user);
39 }
40}
41
42const bob = new UserBuilder("Bob").build();
43const mia = new UserBuilder("Mia").setAddress("123 London Road").build();
44const frankie = new UserBuilder("Frankie")
45 .setAddress("123 London")
46 .setAge(22)
47 .setPhone("0730333321")
48 .build();
1class User {
2 public name: string;
3 public age?: number;
4 public phone?: string;
5 public address?: string;
6 public email?: string;
7 constructor(name: string) {
8 this.name = name;
9 }
10}
11
12class UserBuilder {
13 private user: User;
14 constructor(name: string) {
15 this.user = new User(name);
16 }
17
18 setAge(age: number): UserBuilder {
19 this.user.age = age;
20 return this;
21 }
22 setPhone(phone: string): UserBuilder {
23 this.user.phone = phone;
24 return this;
25 }
26
27 setAddress(address: string): UserBuilder {
28 this.user.address = address;
29 return this;
30 }
31
32 setEmail(email: string): UserBuilder {
33 this.user.email = email;
34 return this;
35 }
36
37 build(): Readonly<User> {
38 return Object.freeze(this.user);
39 }
40}
41
42const bob = new UserBuilder("Bob").build();
43const mia = new UserBuilder("Mia").setAddress("123 London Road").build();
44const frankie = new UserBuilder("Frankie")
45 .setAddress("123 London")
46 .setAge(22)
47 .setPhone("0730333321")
48 .build();

Each of these methods returns this, which allows us to chain them together when creating a User object. This is a common pattern, known as method chaining.

As you can see, this allows us to create a User object in a more readable and elegant way, with each parameter set individually in a fluent API-style syntax.

Bonus

To avoid to get an object with keys as undefined from our builder we can update our build() method to filter out all of those keys that are undefined and has not been set.

1build(): Readonly<User> {
2 // remove keys with undefined values
3 const filteredUser = Object.fromEntries(
4 Object.entries(this.user).filter(([_, value]) => value !== undefined)
5 );
6 return Object.freexe(filteredUser) as Readonly<User>;
7 }
1build(): Readonly<User> {
2 // remove keys with undefined values
3 const filteredUser = Object.fromEntries(
4 Object.entries(this.user).filter(([_, value]) => value !== undefined)
5 );
6 return Object.freexe(filteredUser) as Readonly<User>;
7 }

When to use?

The Builder pattern is useful when dealing with objects that require a large number of parameters or when you want to enforce a certain order of operations during object construction. Here are specific situations where you might want to use the Builder pattern:

  • When you need to create objects with many optional parameters: If you have a class with a large number of optional parameters, it can be difficult and error-prone to create instances of that class using its constructor. In this case, the Builder pattern can simplify the process by allowing you to set only the parameters you need.

  • When you want to enforce a specific order of operations: In some cases, you may want to enforce a specific order of operations during object construction. For example, you may need to set a required parameter before setting an optional parameter. The Builder pattern allows you to define a clear sequence of steps for creating an object, which can help prevent errors and ensure that the object is constructed correctly.

  • When you want to create immutable objects: If you want to create immutable objects, you can use the Builder pattern to enforce that the object is fully initialised before it is returned. This can be useful in situations where you want to ensure that the state of an object cannot be changed after it has been created.

  • When you want to separate object construction from object representation: In some cases, the way an object is constructed may be different from the way it is represented. For example, you may need to convert a JSON object to a class instance, or you may need to create an object from a configuration file. The Builder pattern can be used to separate the process of constructing an object from the process of representing it, making it easier to create objects from a variety of sources.

In general, the Builder pattern is a good choice when you need a flexible and extensible way to create objects, and when you want to simplify object creation by separating it from object representation.