You all know the builder pattern, don’t you?
The Builder pattern provides a way to construct complex objects step by step with a fluent API, where each method call returns the builder itself, allowing for method chaining.
In Java, a typical implementation does not provide the necessary type safety, as calling the final build method does not guarantee that you have called all the required setters. In other words, you don’t get the compile-time guidance when building the object in question.
For example, consider the following code snippet:
record Person(String name, int age) {
public static PersonBuilder builder() {
return new PersonBuilder();
}
}
class PersonBuilder {
private String name = "";
private int age = 0;
public PersonBuilder name(String name) {
this.name = name;
return this;
}
public PersonBuilder age(int age) {
this.age = age;
return this;
}
public Person build() {
return new Person(name, age);
}
}
As you can see, the PersonBuilder
class provides a fluent API for constructing a Person
object. However, there is no compile-time guarantee that you have set all required fields before calling the build()
method. Typically, you circumvent that by assigning default values to the fields.
A typical implementation in Scala provides type safety and a compile-time guarantee that you can build a Person
object only when you have set all required fields.
package builder
case class Person(name: String, age: Int, email: String)
class PersonBuilder[S <: Step] private (
val name: String,
val age: Int,
val email: String
) {
def name(name: String): PersonBuilder[S & Name] =
new PersonBuilder(name, age, email)
def age(age: Int): PersonBuilder[S & Age] =
new PersonBuilder(name, age, email)
def email(email: String): PersonBuilder[S & Email] =
new PersonBuilder(name, age, email)
def build()(implicit ev: S =:= CompletePerson): Person =
Person(name, age, email)
}
object PersonBuilder {
sealed trait Step
sealed trait Empty extends Step
sealed trait Name extends Step
sealed trait Age extends Step
sealed trait Email extends Step
type CompletePerson = Empty & Name & Age & Email
def apply(): PersonBuilder[Empty] = new PersonBuilder("", 0, "")
}
With the above implementation1, the compiler will complain if you try to build a Person
object without setting all required fields.
val person =
PersonBuilder()
.name("John")
.email("[email protected]")
.build() // Oops, we forgot to specify the age
While it is helpful, the compiler error is somewhat obscure:
ERROR: Cannot prove that builder.PersonBuilder.Empty & builder.PersonBuilder.Name &
builder.PersonBuilder.Email =:= builder.PersonBuilder.CompletePerson
Why do we even have access to the build
method before we have set all the required fields? We can do better (in Scala).
To make the build
method available only when all required fields are set, we must use distinct types for each step, as before. However, the trick is to distinguish also by the state of other fields.
package builder
case class PersonBuilder[
N <: Option[String],
A <: Option[Int],
E <: Option[String]
] private (
private[builder] val nameOpt: N,
private[builder] val ageOpt: A,
private[builder] val emailOpt: E
) {
private[builder] def setName(name: String): PersonBuilder[Some[String], A, E] =
copy[Some[String], A, E](nameOpt = Some(name))
private[builder] def setAge(age: Int): PersonBuilder[N, Some[Int], E] =
copy(ageOpt = Some(age))
private[builder] def setEmail(email: String): PersonBuilder[N, A, Some[String]] =
copy(emailOpt = Some(email))
}
object PersonBuilder {
def apply(): PersonBuilder[None.type, None.type, None.type] =
new PersonBuilder(None, None, None)
implicit final class NameOps[L <: Option[String], E <: Option[String]](
private val builder: PersonBuilder[None.type, L, E]
) extends AnyVal {
def name(value: String): PersonBuilder[Some[String], L, E] =
builder.setName(value)
}
implicit final class AgeOps[F <: Option[String], E <: Option[String]](
private val builder: PersonBuilder[F, None.type, E]
) extends AnyVal {
def age(value: Int): PersonBuilder[F, Some[Int], E] =
builder.setAge(value)
}
implicit final class EmailOps[F <: Option[String], L <: Option[String]](
private val builder: PersonBuilder[F, L, None.type]
) extends AnyVal {
def email(value: String): PersonBuilder[F, L, Some[String]] =
builder.setEmail(value)
}
implicit final class BuildDoneOps(
private val builder: PersonBuilder[Some[String], Some[String], Some[String]]
) extends AnyVal {
def build(): Person =
Person(
builder.nameOpt.value,
builder.ageOpt.value,
builder.emailOpt.value
)
}
}
What’s happening here? Let us dissect the implementation.
- We create a builder class with type parameters
N
,A
, andE
to track the setting of thename
,age
, andemail
fields, respectively. The type parameters used in the solution are used only to enforce the setting of required fields and do not incur any runtime cost. - The builder class constructor is private, preventing direct instantiation. Instead, we have to use the
apply
method that starts with an empty instance. - The builder class also provides private setters used by the implicit classes that provide the necessary builder syntax.
- We define implicit classes for each field to provide a fluent API for setting the fields. The implicit classes are defined in such a way that the method in it can be called once per builder for setting the relevant field, although you can set them in any order. But all the (required) fields must be set.
- We define an implicit class
BuildDoneOps
to provide abuild
method that returns aPerson
instance. This implicit class is relevant only when all the required fields are set, and the typePersonBuilder[Some[String], Some[String], Some[String]]
is resolved. So, you won’t have access to thebuild
method at all before you have set all the required fields, which is when the typePersonBuilder[Some[String], Some[String], Some[String]]
is resolved.
Using the code is straightforward. Here’s an example:
val person =
PersonBuilder()
.age(30)
.email("[email protected]")
.name("John Doe") // fields can be set in any order
.build() // build is accessible only when all required fields are set
If you attempt to use the build()
method, say without setting the email
, you will get build method not found
error, which should hint you that one of the fields is not set:
value build is not a member of builder.PersonBuilder2[Some[String], Some[Int], None.type]
Pros: The types let the compiler guide you. So, no obscure error messages. Cons: The implementation is more complex and verbose than the previous one.
You can find the associated code here.
In the next post, I will show you how to restrict to strict ordering and allowing setting fields multiple times.
Photo courtesy: pexels.com