Phantom Types Are Not Spooky

Discover how phantom types enhance the builder pattern in Scala, ensuring all steps are complete before object creation. See practical examples like a database connection builder and learn how compile-time checks improve type safety and prevent errors.

Phantom Types Are Not Spooky
Phantom Types Are Not Spooky: Discover how these friendly 'ghosts' can help with programming tasks, enhancing type safety and expressiveness without runtime overhead.

Introduction

Phantom types are a fascinating and powerful feature in Scala that adds a layer of type safety and expressiveness to our code. Despite their name, phantom types aren't spooky at all. They can act like friendly ghosts, helping us enforce constraints and ensure correctness at compile time without any runtime overhead.

In this article, we'll demystify phantom types, explaining what they are, how they work, and their benefits to our Scala programming. We'll see how these "ghosts" can assist us in creating more robust and maintainable code by providing compile-time guarantees for various conditions and constraints.

Let's dive in and see how these friendly phantoms can help us in our programming tasks.


What Are Phantom Types?

Phantom Types are a concept that is easier to explain with an example. To that end, we will start by explaining the commonly used Fluent Builder pattern and the disadvantages of an implementation entirely done at the value level without using Phantom Types.

The Builder Pattern Explained

In OOP languages, the primary approach is to create instances of a type using constructors. However, this approach is limiting, especially when building complex objects requiring multiple arguments and complex validation for their values and relationships.

In those cases where the simple constructor approach is not expressive enough, a builder pattern is commonly used to construct complex objects step-by-step. It allows us to create an object by specifying its parts or properties one at a time and provides us with the chance to develop a reacher interface to construct the objects:

Scala 3

class DatabaseConnection(val host: String, val port: Int, val database: String)

class DatabaseConnectionBuilder(
    private var host: String = null,
    private var port: Int = 0,
    private var database: String = null
  ):
  def setHost(host: String): DatabaseConnectionBuilder =
    new DatabaseConnectionBuilder(host, port, database)

  def setPort(port: Int): DatabaseConnectionBuilder =
    new DatabaseConnectionBuilder(host, port, database)

  def setDatabase(database: String): DatabaseConnectionBuilder =
    new DatabaseConnectionBuilder(host, port, database)

  def build(): DatabaseConnection =
    new DatabaseConnection(host, port, database)          
        

Although the code above is a simple implementation of the pattern, it is enough to show the limitations of implementing it merely at the value level. It has a significant drawback: it allows the build method to be called at any time, resulting in the creation of an invalid object:

Scala 3

val builder = DatabaseConnectionBuilder()

// The following line build an invalid instance
// because `port` and `database` are not set  
val invalidConnection = builder.setHost("localhost").build()       
        

To mitigate this issue, we can add runtime checks in the build method to ensure all necessary parameters are set.

These checks occur at runtime, so they incur a cost for every object created. However, the critical disadvantage is that they introduce exceptions to such a simple task.

Granted, we could use the return value to communicate whether the builder can create a valid object or not, for example, using a Union Type as the return type in the build method. However, this will still force the client code to be unnecessarily complex. We want as many validations as possible at compile time instead of runtime.

Enter Phantom Types

In the builder example, we want to ensure that all the attributes necessary to create a Database connection have been set before we can call the build method. In other words, the following code should not compile:

Scala 3

val invalidConnection = builder.setHost("localhost").build()       
        

We can achieve that using Phantom Types, which have no values at runtime. They exist exclusively at compile time and carry extra information that enables the compiler to enforce rules and invariants.

A short way to explain them is that they are Generic or Abstract Types that appear only on declarations and never appear in a value position. In other words, our code has no variable bindings, attributes, or method arguments of that type.

To implement them, we'll add a Generic Type Argument for each method we want to ensure called before calling the builder and an Abstract Type representing the state after the corresponding method was called:

Scala 3

type HostSet
type PortSet
type DatabaseSet

class DatabaseConnectionSafeBuilder[Host, Port, Database] private(
    private var host: String = null,
    private var port: Int = 0,
    private var database: String = null
  ):
        

Then, each method returns a builder with the corresponding Phantom Type as a type argument:

Scala 3

type HostSet
type PortSet
type DatabaseSet

class DatabaseConnectionSafeBuilder[Host, Port, Database] private(
    private var host: String = null,
    private var port: Int = 0,
    private var database: String = null
  ):
  def setHost(host: String) = new DatabaseConnectionSafeBuilder[HostSet, Port, Database](host, port, database)

  def setPort(port: Int) = new DatabaseConnectionSafeBuilder[Host, PortSet, Database](host, port, database)

  def setDatabase(database: String) = new DatabaseConnectionSafeBuilder[Host, Port, DatabaseSet](host, port, database)
          
        

Next, we need to write the build method to ensure it can be called only when we have all the data to create a valid instance; for that, we need to ensure the Generic Type is equal to the Abstract Type used to represent we have the required data.

We can do that using an implicit parameter list and the Type Equality Type Class ( =:=), in this case, we need three arguments in that list:

Scala 3

type HostSet
type PortSet
type DatabaseSet

class DatabaseConnectionSafeBuilder[Host, Port, Database] private(
    private var host: String = null,
    private var port: Int = 0,
    private var database: String = null
  ):
  def setHost(host: String) = new DatabaseConnectionSafeBuilder[HostSet, Port, Database](host, port, database)

  def setPort(port: Int) = new DatabaseConnectionSafeBuilder[Host, PortSet, Database](host, port, database)

  def setDatabase(database: String) = new DatabaseConnectionSafeBuilder[Host, Port, DatabaseSet](host, port, database)

  def build()(implicit p1: Host =:= HostSet, p2: Port =:= PortSet, p3: Database =:= DatabaseSet): DatabaseConnection =
    new DatabaseConnection(host, port, database)

object DatabaseConnectionSafeBuilder:
  def apply() = new DatabaseConnectionSafeBuilder[Unit, Unit, Unit]()
          
        

We created an apply method in the companion object to make our builder easier to use. This way, we do not force the client code to be aware and pass the type parameters.

With this builder, we can't call the build method before we have called all the required setters on the builder. Failure to do so results in a compiler error:

Scala 3

    val databaseConnectionSafeBuilder = DatabaseConnectionSafeBuilder()

    databaseConnectionSafeBuilder
      .setHost("localhost")
      .setPort(368)
      .build()
          
        
A terminal output showing a compilation error in Scala using sbt. The error indicates a type mismatch issue where Unit cannot be proven to be equal to DatabaseSet during the build process.
Compilation Error: This terminal output highlights the type safety added to the builder by Phantom Types

Conclusion

A friendly ghost with a sheet over its head, vacuuming a cozy, slightly messy living room, symbolizing the helpful nature of phantom types in programming.
Phantom Types Are Not Spooky: Discover how these 'friendly ghosts' can help you write safer and more reliable code by enforcing compile-time checks in your programs.

We've learned to use Phantom Types to enhance the builder pattern by moving requirement checks from the run to compile time. By leveraging phantom types and compile-time checks, we can prevent invalid instances and eliminate runtime errors, making our code safer and more reliable.

Other scenarios in which we can leverage Scala's Phantom Types:

  1. State Machines: Enforce valid state transitions in finite state machines.
  2. Type-Safe APIs: Ensure that only valid sequences of API calls are made.
  3. Resource Management: Enforce the correct sequence of resource acquisition and release.

In a future article, we'll explore how to use intersection types to reduce the number of type arguments to just one, further simplifying and improving our type-safe builders. Stay tuned!


Addendum: A Special Note for Our Readers

I decided to delay the introduction of subscriptions. You can read the full story here.

In the meantime, I'll be accepting donations. I would appreciate it if you consider a donation, provided you can afford it:

Every donation helps me offset the site's running costs and an unexpected tax bill. Any amount is greatly appreciated.

Also, if you are looking for a fancy way to receive 2025, I am renting my timeshare in Phuket to cover part of the unexpected costs.

Anantara Vacation Club Phuket Mai Khao $2000.00/night
Phuket, Thailand / Posting R1185060

Or share our articles and links page in your social media posts.

Finally, some articles have links to relevant goods and services; buying through them will not cost you more. And if you like programming swag, please visit the TuringTacoTales Store on Redbubble.

Take a look. Maybe you can find something you like: