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.
State | Description |
---|---|
Empty | The initial state (i.e. none of the properties have been set) |
FirstName | The first name is set |
LastName | The last name is set |
The 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.