Blog

Compile-safe builder pattern using Phantom Types in Scala

Scala logo

In this short article, we create a classic builder pattern to demonstrate the power of Phantom Types. Phantom Types provide extra information to the compiler, so that it can introduce extra constraints and check whether they hold at compile time. The program will fail to compile if one or all of the constraints don’t hold. Thus, you can prevent running into costly runtime issues by leveraging Phantom Types. Finally, as a bonus, they do not come with extra runtime overhead, as the Phantom Types are needed only for compilation, and are ultimately erased from the bytecode.

Builder pattern in Java

Firstly, let’s have a look at the builder pattern in Java:

public record Person(String firstName, String lastName, String email) {}

The builder itself could look something like this:

package com.company;

public class PersonBuilder {
    private String firstName;
    private String lastName;
    private String email;
    
    public PersonBuilder firstName(String firstName) {
        this.firstName = firstName;
        return this;
    }
    
    private PersonBuilder lastName(String lastName) {
        this.lastName = lastName;
        return this;
    }
    
    public PersonBuilder email(String email) {
        this.email = email;
        return this;
    }

    public firstName() {
        return this.firstName;
    }

    public lastName() {
        return this.lastName;
    }

    public email() {
        return this.email;
    }

    public Person build() { // Optionally validate and throw exceptions for missing input 
        return new Person(firstName, lastName, email);
    }
}

When the build method is called, there’s no way to guarantee that all the fields of the Person are actually specified. In the above code, that can only be determined during runtime.

Person person = new PersonBuilder()
                      .firstName("Hello")
                      .lastName("World")
                      .build(); // Oops, we forgot to specify the email

Surely, you could introduce exceptions to deal with missing input, but then the code is no longer referential transparent, as side effects could occur and you would need to deal with these as well.

An expression is said to be referentially transparent if it can be replaced by its value without changing the program’s behaviour.

You could decide to write more tests, as the behaviour is only apparent during runtime. However, there’s no need to do this if we can already prevent it in the first place by the Scala compiler. Why test something that is already guarded by the compiler?

Builder pattern in Scala

Let’s share the entire code and then go through it step by step:

import PersonBuilder.{Email, FirstName, FullPerson, LastName, PersonBuilderState}
case class Person(firstName: String, lastName: String, email: String)
object PersonBuilder {
  sealed trait PersonBuilderState
  sealed trait Empty extends PersonBuilderState
  sealed trait FirstName extends PersonBuilderState
  sealed trait LastName extends PersonBuilderState
  sealed trait Email extends PersonBuilderState
  type FullPerson = Empty with FirstName with LastName with Email
  def apply(): PersonBuilder[Empty] = new PersonBuilder("", "", "")
}
class PersonBuilder[State <: PersonBuilderState] private (
    val firstName: String,
    val lastName: String,
    val email: String) {
  def firstName(firstName: String): PersonBuilder[State with FirstName] =
    new PersonBuilder(firstName, lastName, email)
  def lastName(lastName: String): PersonBuilder[State with LastName] =
    new PersonBuilder(firstName, lastName, email)
  def email(email: String): PersonBuilder[State with Email] =
    new PersonBuilder(firstName, lastName, email)
  def build()(implicit ev: State =:= FullPerson): Person = {
    Person(firstName, lastName, email)
  }
}

We can start using the builder as follows:

val person = PersonBuilder()
  .firstName("Hello")
  .lastName("World")
  .build // Oops, we forgot to specify the email

If you try to compile this code, you will not be able to:

Cannot prove that PersonBuilder.Empty with PersonBuilder.FirstName with PersonBuilder.* LastName =:= PersonBuilder.FullPerson

If we squint our eyes a bit, then it basically says:

(Empty with FirstName with LastName) != FullPerson

Which is correct, as our definition of FullPerson demands us to include an e-mail address:

type FullPerson = Empty with FirstName with LastName with Email

When you include the e-mail as well, you’ll see that everything compiles fine again:

val person = PersonBuilder()
  .firstName("Hello")
  .lastName("World")
  .email("hello@world.com") // By adding the e-mail, it will compile again
  .build

Code explanation

So how does this all work? In this paragraph, we go through the code and give more details on the working.

Firstly, we define a set of properties that we would like to use:

sealed trait PersonBuilderState
sealed trait Empty extends PersonBuilderState
sealed trait FirstName extends PersonBuilderState
sealed trait LastName extends PersonBuilderState
sealed trait Email extends PersonBuilderState

In the table below, you can see all the states that we have defined.

StateDescription
EmptyThe initial state (i.e. none of the properties have been set)
FirstNameThe first name is set
LastNameThe last name is set
EmailThe e-mail is set

Secondly, we’ll introduce a new type by specifying that a FullPerson is the combination of an Empty person with a FirstName plus a LastName plus an Email.

type FullPerson = Empty with FirstName with LastName with Email

Next, we create a Type Class where we use a generic and constraint it to be of type PersonBuilderState.

class PersonBuilder[State <: PersonBuilderState]

In addition, we extend the State for each of the methods, to provide additional type information to the compiler:

def firstName(firstName: String): PersonBuilder[State with FirstName]

It takes the State and extends it with FirstName. When we create a new PersonBuilder(), the State will just be Empty. After setting the first name, the State will now be Empty with FirstName. If afterwards we set the e-mail, the State becomes Empty with FirstName with Email. As you can see, the order doesn’t really matter. We only need to make sure that we have a FullPerson the moment we call the build method.

The last part of the puzzle is the actual build method itself:

def build()(implicit ev: State =:= FullPerson)

The magic symbol here is =:=. It is used for expressing equality constraints. In other words, the compiler needs to prove that State at the moment of executing the build method is a FullPerson. If the compiler cannot prove it, you’ll run into a compile error.

Conclusion

And that’s basically it! Phantom Types is a very powerful concept that can be used to make your code a lot more robust. They allow us to catch issues in the earliest stage possible, namely compile time, which is also the cheapest stage to fix issues.

guest
0 Comments
Inline Feedbacks
View all comments

Explore related posts