🙋🏻♀️ 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
ActionPageSheet
or 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.