Type Gymnastics with Builder Pattern

Views
Article hero image

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, and E to track the setting of the name, age, and email 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 a build method that returns a Person instance. This implicit class is relevant only when all the required fields are set, and the type PersonBuilder[Some[String], Some[String], Some[String]] is resolved. So, you won’t have access to the build method at all before you have set all the required fields, which is when the type PersonBuilder[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


  1. Point to note is that the implementation uses Phantom Type (Step). ↩︎

scala builder types