Builder Pattern in TypeScript — ✨ Creational Design Pattern #3

365kim
5 min readMay 1, 2023

--

Photo by Daniel K Cheung on Unsplash

🙋🏻‍♀️ What is the Builder Pattern?

According to the dictionary, builder means a person who constructs something by putting parts together, like an apartment builder.

Likewise, Builder Pattern is a design pattern that helps to create complex objects using a step-by-step approach, making it possible to create different types and representations of an object using a consistent and manageable construction process.

The pattern extracts the construction code from the object to define a class called builder. This process of construction is divided into a series of distinct steps. (setFoo, setBar, … or withFoo, withBar, …)

What’s significant is you don’t have to invoke all the steps. You can selectively call the steps that are needed to produce the particular object.

✨ Simple TypeScript Example

Let’s say we are creating a Dialog component. Following the Builder Pattern, we will write a DialogBuilder class that provides a fluent interface for creating different configurations of Dialogs such as Confirm , Snackbar ActionPageSheetor any other Dialogs.

First, we have an interface called IDialogBuilder which defines the expected shape of the styles that can be applied to the dialog.

interface IDialogBuilder {
header?: {
size: 'small' | 'medium';
};
body?: {
width: 'full' | '400px';
height: 'full' | 'hug';
};
footer?: {
leftButton?: 'primary' | 'secondary';
rightButton?: 'primary' | 'secondary';
alignment?: 'left' | 'center' | 'right';
};
}

Next, we have an interface called DialogContent which represents the content that can be passed into the render method (typed as RenderDialog) of the Dialog class.

interface DialogContent {
header?: React.ReactNode;
body?: React.ReactNode;
footer?: { left?: React.ReactNode; right?: React.ReactNode };
}

type RenderDialog = (content: DialogContent) => React.ReactNode;

Here, the DialogBuilder class is defined, which is a class that allows the creation of dialog objects with specific styles. Each of these methods returns the DialogBuilder instance, so that you can chain multiple method calls together in a single statement. (you’ll see it later.)

class DialogBuilder {
private renderHeader?: (content: DialogContent['header']) => React.ReactNode;
private renderBody?: (content: DialogContent['body']) => React.ReactNode;
private renderFooter?: (content: DialogContent['footer']) => React.ReactNode;

setHeaderStyle(headerStyle: IDialogBuilder['header']): DialogBuilder {
this.renderHeader = (content: DialogContent['header']) => {
const height = headerStyle?.size === 'small' ? 40 : 56;
return <header style={{ height }}>{content}</header>;
};
return this;
}

setBodyStyle(bodyStyle: IDialogBuilder['body']): DialogBuilder {
this.renderBody = (content: DialogContent['body']) => {
const width = bodyStyle?.width === 'full' ? '100%' : 400;
const height = bodyStyle?.height === 'full' ? '100%' : 'auto';

return <div style={{ width, height }}>{content}</div>;
};

return this;
}

setFooterStyle(footerStyle: IDialogBuilder['footer']): DialogBuilder {
this.renderFooter = (content: DialogContent['footer']) => {
const buttonStyle = {
primary: {
color: 'white',
backgroundColor: 'blue',
},
secondary: {
color: 'blue',
backgroundColor: 'white',
},
};

return (
<footer
style={{
display: 'flex',
justifyContent:
footerStyle?.alignment === 'left'
? 'flex-start'
: footerStyle?.alignment === 'right'
? 'flex-end'
: footerStyle?.alignment,
}}
>
{content?.left && (
<button style={{ ...buttonStyle[footerStyle?.leftButton as keyof typeof buttonStyle] }}>
{content?.left}
</button>
)}
{content?.right && (
<button style={{ ...buttonStyle[footerStyle?.rightButton as keyof typeof buttonStyle] }}>
{content?.right}
</button>
)}
</footer>
);
};

return this;
}

build(): Dialog {
const render = (content: DialogContent) => {
return (
<div role="dialog">
{this.renderHeader?.(content.header)}
{this.renderBody?.(content.body)}
{this.renderFooter?.(content.footer)}
</div>
);
};

return new Dialog(render); // ✅
}
}

The build method at last returns a new Dialog instance that has been configured with the styles you specified.

It is noteworthy that by using the Builder Pattern, you are able to separate the construction of the Dialog class from its representation. This has resulted in a much simpler Dialog class, as opposed to having to deal with a longer and more complex code all at once.

class Dialog {
render: RenderDialog;

constructor(render: RenderDialog) {
this.render = render;
}

foo() {
console.log('foo');
}
bar() {
console.log('bar');
}
}

Now, it’s time to create a concrete builder!

const dialogBuilder = new DialogBuilder();

With the instance dialogBuilder, you can set the desired styles of a dialog step by step, and at last, you can call the build() method to create a new Dialog object.

const actionPageSheet = dialogBuilder
.setHeaderStyle({ size: 'medium' })
.setBodyStyle({ width: '400px', height: 'hug' })
.setFooterStyle({ leftButton: 'secondary', rightButton: 'primary', alignment: 'right' })
.build();

Finally, you can use the common behaviors of Dialog like this:

const snackbar = dialogBuilder.setBodyStyle({ width: '400px', height: 'hug' }).build();

snackbar.render({
body: 'Item deleted successfully.',
});

snackbar.render({
body: 'Failed to delete Item.',
});

Of course you can reuse the builder created from DialogBuilder several times. Here you have confirm instead of actionPageSheet

const confirm = dialogBuilder
.setBodyStyle({ width: '400px', height: 'hug' })
.setFooterStyle({ leftButton: 'secondary', rightButton: 'primary', alignment: 'right' })
.build();

confirm.render({
body: 'Are you sure to delete this item?',
footer: {
left: 'Cancel',
right: 'Confirm',
},
});

Notice that you don’t have to call setHeaderStyle if you don’t need it.

You can even reuse the concrete snackbar created from dialogBuilder several times like this:

const snackbar = dialogBuilder.setBodyStyle({ width: '400px', height: 'hug' }).build();

snackbar.render({
body: 'Item deleted successfully',
});
snackbar.render({
body: 'Item added successfully',
});

🧑🏻‍💻 Use it or Avoid it

When to use it

It’s a good strategy to use the Builder Pattern when an object has many optional parameters, because the more optional parameters, the more difficult to keep track of all steps involved and create an instance of the object with only the desired parameters. With the Builder Pattern the process can be simplified! This provides a clear and concise API for constructing an object, even increasing the readability of code.

This is especially true if there are multiple representations of an object, such as different visual representations of a user interface component like in the case of Dialog. The Builder Pattern can help create each representation using the same construction process.

When to avoid it

But… if the object being constructed is simple, using Builder Pattern might be overkill and could add unnecessary complexity.

Also, if the builder and the object being constructed are too tightly coupled, changes to one might require changes to the other, making maintenance more difficult. In such cases, an alternative pattern like Factory Method Pattern(#1) or Abstract Factory Pattern(#2) might be a better fit.

In short, although it might not be necessary for simpler objects, the Builder Pattern is a powerful tool for simplifying the construction of complex objects with many optional parameters, or when multiple representations of an object are required.

--

--

365kim

Web Front-End Developer who believes Every Day Counts!