4 Dangerous Problems in JavaScript Easily Solved by The Builder Design Pattern
jsmanifest
Posted on November 25, 2019
Find me on medium
When you're developing apps in JavaScript you sometimes find it difficult to construct objects that are complex. Once it hits this certain point in your code, it becomes more important as it can get way more complex as your app gets larger.
The "complex"ity can come in several forms. One could be that your code gets repetitive when you're trying to create different variations of certain objects. Another one could be that attempting to create those variations of objects can become quite long because you'd be having to do the logic in one giant block somewhere, like during the constructor block of a class.
This article will go over these problems and will show how the Builder Design Pattern in JavaScript will make those problems much less of an issue.
So what are the problems that the Builder pattern can easily solve?
Let's first look at an example without the builder pattern, and then an example with the builder pattern so that i'm not the only one with a visual code example in mind as we're going along:
In the following code examples, we're defining a Frog
class. We'll pretend that in order for the Frog
class to be fully capable of living and venturing out in the wild without a problem, they would require two eyes, all four legs, a scent, a tongue, and a heart. Now obviously in the real world there's a lot more involved and it sounds ridiculous to require a scent to be able to live, but we'll just keep it both simple and interesting rather than being fully factual about everything. We can get our facts 100% correct in another post at another time :)
Without the builder pattern
class Frog {
constructor(name, gender, eyes, legs, scent, tongue, heart, weight, height) {
this.name = name
this.gender = gender
this.eyes = eyes
this.legs = legs
this.scent = scent
this.tongue = tongue
this.heart = heart
if (weight) {
this.weight = weight
}
if (height) {
this.height = height
}
}
}
With the builder pattern
class FrogBuilder {
constructor(name, gender) {
this.name = name
this.gender = gender
}
setEyes(eyes) {
this.eyes = eyes
return this
}
setLegs(legs) {
this.legs = legs
return this
}
setScent(scent) {
this.scent = scent
return this
}
setTongue(tongue) {
this.tongue = tongue
return this
}
setHeart(heart) {
this.heart = heart
return this
}
setWeight(weight) {
this.weight = weight
return this
}
setHeight(height) {
this.height = height
return this
}
}
Now this seems a little overkill because the builder pattern example is larger in code. But if you dig deeper into all the cases that would occur during the development of a potential frog application, you will see that by looking at these two examples, the code example with the builder pattern applied will slowly rise in promoting simplicity, maintainability, and opening more opportunities to implement robust functionality.
Here are the 4 problems that the Builder Design Pattern can easily solve in JavaScript:
1. Code clutter and confusion
It's not uncommon that errors and accidents occur from carelessness of developing in large sizes of function blocks. In addition, when there are too many things going on in a single block, it is easy to get confused.
So what what kind of situation would you get into when there are "too many things going on" in function blocks, like the constructor?
Going back at our first code example implemented without the builder pattern, lets assume we have to add in some additional logic in order to accept the passed in arguments before applying them into an instance:
class Frog {
constructor(name, gender, eyes, legs, scent, tongue, heart, weight, height) {
if (!Array.isArray(legs)) {
throw new Error('Parameter "legs" is not an array')
}
// Ensure that the first character is always capitalized
this.name = name.charAt(0).toUpperCase() + name.slice(1)
this.gender = gender
// We are allowing the caller to pass in an array where the first index is the left eye and the 2nd is the right
// This is for convenience to make it easier for them.
// Or they can just pass in the eyes using the correct format if they want to
// We must transform it into the object format if they chose the array approach
// because some internal API uses this format
this.eyes = Array.isArray(eyes) ? { left: eye[0], right: eye[1] } : eyes
this.legs = legs
this.scent = scent
// Pretending some internal API changed the field name of the frog's tongue from "tongueWidth" to "width"
// Check for old implementation and migrate them to the new field name
const isOld = 'tongueWidth' in tongue
if (isOld) {
const newTongue = { ...tongue }
delete newTongue['tongueWidth']
newTongue.width = tongue.width
this.tongue = newTongue
} else {
this.tongue = newTongue
}
this.heart = heart
if (typeof weight !== 'undefined') {
this.weight = weight
}
if (typeof height !== 'undefined') {
this.height = height
}
}
}
const larry = new Frog(
'larry',
'male',
[{ volume: 1.1 }, { volume: 1.12 }],
[{ size: 'small' }, { size: 'small' }, { size: 'small' }, { size: 'small' }],
'sweaty socks',
{ tongueWidth: 18, color: 'dark red', type: 'round' },
{ rate: 22 },
6,
3.5,
)
Our constructor is a little long, and in some cases it doesn't even seem like a lot of the logic won't even be necessary. It is cluttered by logic of handling different parameters. This can be confusing especially if we haven't looked at the source code of this in a long time.
When we're developing a frog application and we want to instantiate an instance of a Frog
, the downside is that we would have to make sure that we get every parameter near 100% perfect in terms of following the function signature or something will throw during the construction phase. If we need to double check the type of eyes
at some point, we would have to scan through the clutter of code to get to the code we're looking for. Would you start being confused if you finally found the lines you were looking for, but then realized there was another line of code that was referencing and affecting the same parameter just 50 lines above? Now you have to go back and scan through those to be able to understand what will happen.
If we take another look at the FrogBuilder
constructor from an earlier example, we're able to simplify the constructor to feel more "natural" while removing the confusion. We would still be doing the extra validations, it would just be isolated into their own little methods, which is the heart and soul of the builder pattern.
2. Readability
If we take a look at the most recent code example, it's already getting a little hard to read because we have to process these different variations of handling at once. There's no way around it than to understand the whole thing at once if we wanted to create instances of a Frog
.
In addition, we have to provide some documentation otherwise we'd be unsure why in the world is tongueWidth
being renamed to width
. This is absurd!
If we convert the example to use the builder pattern, we can make things more easily readable:
class FrogBuilder {
constructor(name, gender) {
// Ensure that the first character is always capitalized
this.name = name.charAt(0).toUpperCase() + name.slice(1)
this.gender = gender
}
formatEyesCorrectly(eyes) {
return Array.isArray(eyes) ? { left: eye[0], right: eye[1] } : eyes
}
setEyes(eyes) {
this.eyes = this.formatEyes(eyes)
return this
}
setLegs(legs) {
if (!Array.isArray(legs)) {
throw new Error('"legs" is not an array')
}
this.legs = legs
return this
}
setScent(scent) {
this.scent = scent
return this
}
updateTongueWidthFieldName(tongue) {
const newTongue = { ...tongue }
delete newTongue['tongueWidth']
newTongue.width = tongue.width
return newTongue
}
setTongue(tongue) {
const isOld = 'tongueWidth' in tongue
this.tongue = isOld
? this.updateTongueWidthFieldName(tongue, tongue.tongueWidth)
: tongue
return this
}
setHeart(heart) {
this.heart = heart
return this
}
setWeight(weight) {
if (typeof weight !== 'undefined') {
this.weight = weight
}
return this
}
setHeight(height) {
if (typeof height !== 'undefined') {
this.height = height
}
return this
}
build() {
return new Frog(
this.name,
this.gender,
this.eyes,
this.legs,
this.scent,
this.tongue,
this.heart,
this.weight,
this.height,
)
}
}
const larry = new FrogBuilder('larry', 'male')
.setEyes([{ volume: 1.1 }, { volume: 1.12 }])
.setScent('sweaty socks')
.setHeart({ rate: 22 })
.setWeight(6)
.setHeight(3.5)
.setLegs([
{ size: 'small' },
{ size: 'small' },
{ size: 'small' },
{ size: 'small' },
])
.setTongue({ tongueWidth: 18, color: 'dark red', type: 'round' })
.build()
We gained the ability to make our code much more readable in a couple of ways:
- The names of the methods are sufficiently self-documenting
-
updateTongueWidthFieldName
easily defines to us what it does and why it's doing it. We know that its updating the field name. And we also know why because the word "update" already means to bring up to date! This self-documented code helps us assume that some field name is old and needs to be changed to use the new field name.
- The constructor is short and simplified.
- It's perfectly fine to set the other properties later!
- Can clearly understand each parameter when initiating a new
Frog
- It's like reading English. You're clearly setting the eyes, legs, etc and finally invoking the build method to create a
Frog
.
- Each logic is now isolated in separate blocks where we can easily follow through
- When you're doing some changes you only need to focus on one thing, which is what ever that got isolated in function blocks.
3. Lack of control
The most important one on this list is benefiting from more control over the implementation. Prior to the builder example, it is possible to write more code in the constructor, but the more code you try to stick in there the more it degrades readability which causes clutter and confusion.
Since we're able to isolate implementation details to each of their own function blocks, we now have finer control in many ways.
One way is that we can add validations without even adding more problems, which makes the construction phase more robust:
setHeart(heart) {
if (typeof heart !== 'object') {
throw new Error('heart is not an object')
}
if (!('rate' in heart)) {
throw new Error('rate in heart is undefined')
}
// Assume the caller wants to pass in a callback to receive the current frog's weight and height that he or she has set
// previously so they can calculate the heart object on the fly. Useful for loops of collections
if (typeof heart === 'function') {
this.heart = heart({
weight: this.weight,
height: this.height
})
} else {
this.heart = heart
}
return this
}
validate() {
const requiredFields = ['name', 'gender', 'eyes', 'legs', 'scent', 'tongue', 'heart']
for (let index = 0; index < requiredFields.length; index++) {
const field = requiredFields[index]
// Immediately return false since we are missing a parameter
if (!(field in this)) {
return false
}
}
return true
}
build() {
const isValid = this.validate(this)
if (isValid) {
return new Frog(
this.name,
this.gender,
this.eyes,
this.legs,
this.scent,
this.tongue,
this.heart,
this.weight,
this.height,
)
} else {
// just going to log to console
console.error('Parameters are invalid')
}
}
We took advantage of the fact that each part of the constructor is isolated by adding in validations as well as a validate
method to ensure that all of the required fields have been set before finally building the Frog
.
We can also take advantage of these opened opportunities to add further custom input data types to build the original return value of a parameter.
For example we can add more custom ways the caller can pass in eyes
, to provide them even more convenience than what we previously provided:
formatEyesCorrectly(eyes) {
// Assume the caller wants to pass in an array where the first index is the left
// eye, and the 2nd is the right
if (Array.isArray(eyes)) {
return {
left: eye[0],
right: eye[1]
}
}
// Assume that the caller wants to use a number to indicate that both eyes have the exact same volume
if (typeof eyes === 'number') {
return {
left: { volume: eyes },
right: { volume: eyes },
}
}
// Assume that the caller might be unsure of what to set the eyes at this current moment, so he expects
// the current instance as arguments to their callback handler so they can calculate the eyes by themselves
if (typeof eyes === 'function') {
return eyes(this)
}
// Assume the caller is passing in the directly formatted object if the code gets here
return eyes
}
setEyes(eyes) {
this.eyes = this.formatEyes(eyes)
return this
}
This way it makes it easier for the caller to choose any variation of input types they want:
// variation 1 (left eye = index 1, right eye = index 2)
larry.setEyes([{ volume: 1 }, { volume: 1.2 }])
// variation 2 (left eye + right eye = same values)
larry.setEyes(1.1)
// variation 3 (the caller calls the shots on calculating the left and right eyes)
larry.setEyes(function(instance) {
let leftEye, rightEye
let weight, height
if ('weight' in instance) {
weight = instance.weight
}
if ('height' in instance) {
height = instance.height
}
if (weight > 10) {
// It's a fat frog. Their eyes are probably humongous!
leftEye = { volume: 5 }
rightEye = { volume: 5 }
} else {
const volume = someApi.getVolume(weight, height)
leftEye = { volume }
// Assuming that female frogs have shorter right eyes for some odd reason
rightEye = { volume: instance.gender === 'female' ? 0.8 : 1 }
}
return {
left: leftEye,
right: rightEye,
}
})
// variation 4 (caller decides to use the formatted object directly)
larry.setEyes({
left: { volume: 1.5 },
right: { volume: 1.51 },
})
4. Boilerplate (Solved by: Templating)
One concern we might come across in the future is that we end up with some repetitive code.
For example, looking back at our Frog
class, do you think that when we want to create certain types of frogs, some of them might have the same exact traits?
In a real world scenario, there are different variations of frogs. A toad for example is a type of a frog, but not all frogs are toads. So that tells us that there are some distinctive properties of a toad that should not belong to normal frogs.
One difference between toads and frogs is that toads spend most of their time on land as opposed to normal frogs who spend most of their time inside water. In addition, toads also have dry bumpy skin whereas the skin of normal frogs are a little slimy.
That means we're going to have to ensure some how that every time a frog is instantiated, only some values can make it through as well as some values must make it through.
Let's go back to our Frog
constructor and add in two new parameters: habitat
, and skin
:
class Frog {
constructor(
name,
gender,
eyes,
legs,
scent,
tongue,
heart,
habitat,
skin,
weight,
height,
) {
this.name = name
this.gender = gender
this.eyes = eyes
this.legs = legs
this.scent = scent
this.tongue = tongue
this.heart = heart
this.habitat = habitat
this.skin = skin
if (weight) {
this.weight = weight
}
if (height) {
this.height = height
}
}
}
Making two simple changes to this constructor was already a little confusing! This is why the builder pattern is recommended. If we put the habitat
and skin
parameters at the end, it might cause bugs because weight
and height
can possibly be undefined since they are both optional! And since they are optional, if the caller doesn't pass those in, then habitat
and skin
will mistakenly be used for them. Yikes!
Lets edit the FrogBuilder
to support habitat
and skin
:
setHabitat(habitat) {
this.habitat = habitat
}
setSkin(skin) {
this.skin = skin
}
Lets now pretend we need to create 2 separate toads and 1 normal frog:
// frog
const sally = new FrogBuilder('sally', 'female')
.setEyes([{ volume: 1.1 }, { volume: 1.12 }])
.setScent('blueberry')
.setHeart({ rate: 12 })
.setWeight(5)
.setHeight(3.1)
.setLegs([
{ size: 'small' },
{ size: 'small' },
{ size: 'small' },
{ size: 'small' },
])
.setTongue({ width: 12, color: 'navy blue', type: 'round' })
.setHabitat('water')
.setSkin('oily')
.build()
// toad
const kelly = new FrogBuilder('kelly', 'female')
.setEyes([{ volume: 1.1 }, { volume: 1.12 }])
.setScent('black ice')
.setHeart({ rate: 11 })
.setWeight(5)
.setHeight(3.1)
.setLegs([
{ size: 'small' },
{ size: 'small' },
{ size: 'small' },
{ size: 'small' },
])
.setTongue({ width: 12.5, color: 'olive', type: 'round' })
.setHabitat('land')
.setSkin('dry')
.build()
// toad
const mike = new FrogBuilder('mike', 'male')
.setEyes([{ volume: 1.1 }, { volume: 1.12 }])
.setScent('smelly socks')
.setHeart({ rate: 15 })
.setWeight(12)
.setHeight(5.2)
.setLegs([
{ size: 'medium' },
{ size: 'medium' },
{ size: 'medium' },
{ size: 'medium' },
])
.setTongue({ width: 12.5, color: 'olive', type: 'round' })
.setHabitat('land')
.setSkin('dry')
.build()
So where is the repetitive code in this?
If we look closely, notice we have to repeat the toad's habitat
and skin
setters. What if there were 5 more setters that are exclusive only to toads? We would have to manually apply this template for toads every time we create them--the same goes for normal frogs.
What we can do is to create a templater, which is normally by convention called the Director.
The Director is responsible for executing steps to create objects--usually where there are some common structures that could be defined beforehand when building the final object, like in this case our toad.
So instead of having to manually set the distinctive properties between toads, we can have the director generate that template for us:
class ToadBuilder {
constructor(frogBuilder) {
this.builder = frogBuilder
}
createToad() {
return this.builder.setHabitat('land').setSkin('dry')
}
}
let mike = new FrogBuilder('mike', 'male')
mike = new ToadBuilder(mike)
.setEyes([{ volume: 1.1 }, { volume: 1.12 }])
.setScent('smelly socks')
.setHeart({ rate: 15 })
.setWeight(12)
.setHeight(5.2)
.setLegs([
{ size: 'medium' },
{ size: 'medium' },
{ size: 'medium' },
{ size: 'medium' },
])
.setTongue({ width: 12.5, color: 'olive', type: 'round' })
.build()
That way, you avoid implementing the boilerplate that all toads share in common and can focus only on the properties you need. This becomes more useful when there are even more properties exclusive only to toads.
Conclusion
And that concludes the end of this post! I hope you found this to be valuable and look out for more in the future!
Find me on medium
Posted on November 25, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 27, 2024
October 21, 2024