1. Introduction

The problem we are analyzing in this article is the construction of complicated objects with many parameters, some of them possibly optional.

We could create several constructors, one for every possible combination of parameters. Unfortunately, that gets out of hand very quickly. It’s lengthy and error-prone, difficult to maintain, and doesn’t really help the developers much, as they have to find and choose the right constructor among many.

Alternatively, we could provide a minimal constructor and then a bunch of setters, but that’s not idiomatic nor functional at all. Setters are reminiscent of object-oriented programming, and they’re the exact opposite of immutability.

Luckily for us, there is a third way, using native Scala features, to implement a more elegant solution to this problem: the builder pattern.

2. Solution

The core idea behind the builder pattern is that only a very basic constructor is needed and that the rest of the parameters can then be specified using chained calls to methods, which are specific to the parameters.

For example, let’s imagine that we want to set up a guitar for a music-synthesizing application. The parameters might consider:

  • Acoustic or electric? – default to electric
  • Number of strings (6, 7, or 8) – default to 6
  • Tuning (standard, drop-d, double-drop-d, dadgad) – default to standard
  • Tone (from an enumeration) – default to clean
  • Reverb (a floating number between 0 and 1) – default to 0
  • Delay (an integer between 0 and 2000 milliseconds) – default to 0

Instead of writing a constructor with six parameters (cumbersome and error-prone), or in addition to it, we want to be able to instantiate a guitar more expressively:

val guitar = GuitarBuilder()
  .withElectric(true)
  .withTone("brit-j800")
  .build()

This is cool because this way, the users of the builder don’t need to know all the possible parameters, nor do they have to decide on the values for them if they don’t want to. They also don’t need to know the order in which the parameters are expected.

To achieve that, our builder will rely on two characteristics of the Scala language: named parameters and default values. But first, we need a model for our guitar:

case class Guitar(
  isElectric: Boolean = false,
  numberOfStrings: Int = 6,
  tuning: String = "standard",
  tone: String = "clean",
  reverb: Float = 0.0f,
  delay: Int = 0
)

Although not strictly necessary, we’ve provided named parameters and default values for those in the model, just to be able to instantiate guitars if we don’t want to use the builder. But for the builder itself, those are key features, so it might look like:

case class GuitarBuilder private (
  isElectric: Boolean = false,
  numberOfStrings: Int = 6,
  tuning: String = "standard",
  tone: String = "clean",
  reverb: Float = 0.0f,
  delay: Int = 0
) {
  def withElectric(isElectric: Boolean): GuitarBuilder =
    copy(isElectric = isElectric)

  def withNStrings(nStrings: Int): GuitarBuilder =
    copy(numberOfStrings = nStrings)

  def withTuning(tuning: String): GuitarBuilder = copy(tuning = tuning)

  def withTone(tone: String): GuitarBuilder = copy(tone = tone)

  def withReverb(reverb: Float): GuitarBuilder = copy(reverb = reverb)

  def withDelay(delay: Int): GuitarBuilder = copy(delay = delay)

  def build() = Guitar(
    isElectric = isElectric,
    numberOfStrings = numberOfStrings,
    tuning = tuning,
    tone = tone,
    reverb = reverb,
    delay = delay
  )
}

We can see that the builder is basically a wrapper for the model, with all of its parameters (as private attributes). In this case, the fact that they are named and with default values is very important, because that allows constructs like:

def withTone(tone: String): GuitarBuilder = copy(tone = tone)

Every time one of those modifier methods is invoked, a new builder is constructed from the old one, with only one of the parameters changed to the value provided. At the end of the chain of modifying invocations, we call the build() method, and that’s where the actual instance of Guitar is created, with all the accumulated modifications to its parameters.

3. Testing

Let’s test our solution using ScalaTest. Let’s first test that with no specifications of any kind, a default guitar will be built:

"an unsafe guitar builder" should {
  "resort to defaults when not initialised" in {
    val expectedGuitar = Guitar(
      isElectric = false,
      numberOfStrings = 6,
      tuning = "standard",
      tone = "clean",
      reverb = 0.0f,
      delay = 0
    )
    val actualGuitar = GuitarBuilder().build()
    assertResult(expectedGuitar)(actualGuitar)
  }
}

Now, let’s build another guitar, passing only some of the parameters. This time, the number of strings will take its default value of six:

"an unsafe guitar builder" should {
  "initialise from just some values" in {
    val expectedGuitar = Guitar(
      isElectric = true,
      numberOfStrings = 6,
      tuning = "dadgad",
      tone = "brit-j800",
      reverb = 0.2f
    )
    val actualGuitar = GuitarBuilder()
      .withElectric(true)
      .withTuning("dadgad")
      .withTone("brit-j800")
      .withReverb(0.2f)
      .build()
    assertResult(expectedGuitar)(actualGuitar)
  }
}

4. Type Safety (Advanced Topic)

The mechanism described above is versatile and easy to use. Sadly, it doesn’t detect issues that could arise from using incompatible, duplicated, or missing modifiers. We could validate the parameters in advance, or we could validate the object being built, but wouldn’t it be nice if the compiler told us that the code has issues?

That is actually possible, but it requires the use of another feature of the language: type constraints. It is more advanced than what we’ve used so far, though.

Let’s reduce the builder for simplicity. It only recognizes whether a guitar is electric and whether there is a reverb. Let’s also assume that only electric guitars can produce reverb. So, if we try to build an acoustic guitar with reverb, the compiler will fail to compile the code.

Our safe guitar builder could look like:

sealed trait TBoolean
sealed trait TTrue extends TBoolean
sealed trait TFalse extends TBoolean

case class SafeGuitarBuilder[Electric <: TBoolean] private (
  isElectric: Boolean = false,
  numberOfStrings: Int = 6,
  tuning: String = "standard",
  tone: String = "clean",
  reverb: Float = 0.0f,
  delay: Int = 0
) {
  def electric: SafeGuitarBuilder[TTrue] = 
    copy[TTrue](isElectric = true)

  def withReverb(reverb: Float)(implicit ev: Electric =:= TTrue): SafeGuitarBuilder[TTrue] = 
    copy[TTrue](reverb = reverb)

  def build(): Guitar = Guitar(
    isElectric = isElectric,
    numberOfStrings = numberOfStrings,
    tuning = tuning,
    tone = tone,
    reverb = reverb,
    delay = delay
  )
}

object SafeGuitarBuilder {
  def apply() = new SafeGuitarBuilder[TFalse]()
}

The first thing we note is that the builder takes a type parameter, Electric, which can be either TTrue or TFalse. This way, in reality, we’ll have not one but two types for the builder: SafeGuitarBuilder[TTrue] and SafeGuitarBuilder[TFalse]. And this is how we let the compiler know if the withReverb(reverb: Float) modifier is applicable or not: by the type to which it’s applied!

At compile time, the compiler will look for evidence that Electric is TTrue. That’s what the =:= operator does in the implicit declaration of the evidence. If it can’t find it, the code will not compile, and we’ll get a type error.

Let’s test it:

"a safe guitar builder" should {
  "allow reverb only in electric guitars" in {
    assertTypeError("""
        |val acousticGuitar = SafeGuitarBuilder()
        |          .withReverb(0.2f)
        |          .build()
        |""".stripMargin)
    val electricGuitar = SafeGuitarBuilder().electric
      .withReverb(0.2f)
      .build()
  }
}

When we call withReverb(0.2f) on an acoustic guitar, we return a type error captured by the test. But when we call the modifier electric before withReverb(0.2f), everything works fine.

Needless to say, we can extend this technique to perform much more complex checks. The users of our builders will love us.

5. Conclusion

In this article, we’ve explored a simple way to implement the useful builder pattern in Scala. In its simplest form, it should be enough for many purposes.

Then, we extended the implementation to add type safety at compile time using type constraints. By doing so, we make our code more complex, but we also give the user the benefit of compile-time detection of wrong applications of the pattern.

As usual, the full code for this article is available over on GitHub.

Comments are open for 30 days after publishing a post. For any issues past this date, use the Contact form on the site.