Phantom builder pattern in Elm

Josh Bebbington
Carwow Product, Design & Engineering
7 min readApr 26, 2020
Photo by Kevin Ku on Unsplash

Elm’s simple, expressive type system gives us some powerful tools to constrain the inputs and outputs of our functions. Without it, it’s hard to imagine how Elm could give us the nice compile-time guarantees about the safety and correctness of our programs, or the developer-friendly error messages that the language promises.

In this post we’ll explore the Phantom builder pattern[1]: how phantom types — an advanced feature of the type system — can be combined with the builder pattern to prevent misconfiguration of our data structures, and provide clear helpful error messages to developers working with our code.

The Builder Pattern

It’s common for functions to accept arguments that are used to modify the returned values. If we were building a Button module we might expose an init function that accepts a custom colour and some text, and produces a Button value constructed with those attributes:

type Button
= Button String String
init : String -> String -> Button
init colour text =
Button colour text

Our Button would then be used within a view function, and rendered with our chosen colour and text.

As we add more configurable options to our Button type we could consider encapsulating that data in a record, so that the list of arguments given to init doesn't continue to grow with each new attribute. We’ve done that here to support an optional icon image:

type alias Struct =
{ colour : String
, text : String
, icon : Maybe String
}
type Button
= Button Struct
init : Struct -> Button
init struct =
Button struct

Whenever someone attempts to call our code with a struct that is missing an attribute, or attempts to provide a value within that struct of an incorrect type, they’ll get a helpful compile time error message.

Whilst this example isn’t particularly complicated, it could easily get complex as we add more features. Perhaps we have a niche product requirement to have a border on some buttons, or default text on others, all of which add complexity to the process of constructing a button.

With the Struct type alias above, anyone using our Button also has to know how to construct a record with those exact attributes, along with the implementation detail of each attribute (colour is required to be a string, icon must be a Maybe String etc.)

We want to provide a clean API for our module that:

  • Doesn’t leak internal implementation details of our button attributes (like icon being a Maybe String)
  • Provides sensible default values for button attributes that are optional (a default colour value for example), that can be set by the caller if desired.

A typical way to achieve this is by using the builder pattern:

type alias Struct =
{ colour : String
, text : String
, icon : Maybe String
}

type Button
= Button Struct
init : Button
init =
Button { colour = "green", text = "Click me!", icon = Nothing }
withColour : String -> Button -> Button
withColour colour (Button struct) =
Button { struct | colour = colour }
withIcon : String -> Button -> Button
withIcon icon (Button struct) =
Button { struct | icon = Just icon }

Whilst our Struct and Button definitions have not changed, we now return a valid button from our init function with sensible defaults, and expose functions for some optionally configurable attributes that lend themselves to composition. All of the following examples will produce valid buttons:

init
init |> withColour "green"
init |> withIcon "arrow"
init |> withColour "green" |> withIcon "arrow"
init |> withIcon "arrow" |> withColour "green"

Note: We can chain multiple withX functions in any order, as the pipe operator (|>) pipes the result of the left hand side as the final argument to the right hand side of the function call.

Whilst the builder pattern helps us expose a clear interface for our Button type, it has some potential downsides. As a user of our Button module not familiar with its internal implementation, how might you expect the following code to behave?

init |> withIcon "arrow-left" |> withIcon "arrow-right"

Maybe our button will show the arrow-left icon or the arrow-right icon, or maybe two icons will appear! The truth is that we can't know without digging into the implementation of withIcon.

We want to make the snippet above impossible — attempts to misconfigure our button should result in a friendly compile time error that guides the developer towards a correct implementation, and doesn’t require them to dig into the internals of our module.

Phantom types

Phantom types are types with variables that are unused by any of their constructors. We won’t go into too much depth on them here, but there are several excellent primers you should read if you are interested — (Modelling currency in Elm[2], and Advanced Types in Elm — Phantom Types[3]) They look something like this:

type Address a
= AddressData (List AddressLine)

The variable a is unused by the single AddressData constructor of the Address type, so Address is considered a phantom type.

This is incredibly useful for constraining inputs to our functions. If we wanted to only permit valid addresses to be saved, we could make use of the type variable to tag instances of Address with information about the validity of its contents:

type Address a
= AddressData (List AddressLine)
type NotValidated
= NotValidated
type Validated
= Validated
newAddress : Address NotValidated
newAddress =
-- Construct an address, not validated by default
validateAddress : Address NotValidated -> Address Validated
validateAddress address =
-- Accept an un-validated address, perform any validations, and produce a valid address
saveAddress : Address Validated -> Cmd msg
saveAddress address =
-- Attempts to save an `Address NotValidated` will result in a compiler error

By constraining our types in this way, we have imposed a condition on when an address can be saved, and have a nice compile-time guarantee that only validated addresses can be saved. If a user attempts to save an invalid address they will get a clear error message informing them of the problem:

The error produced when passing an un-validated address to the save address function, which will only accept a validated address

The Phantom builder pattern

We can make use of phantom types with the builder pattern to prevent misconfiguration of our Button data structure. As a reminder, our goal is to produce a compile time error if a user attempts to configure a button with more than one icon, and produce a friendly error message if they attempt to do so:

init |> withIcon "arrow-left" |> withIcon "arrow-right"

Let’s start by making Button a phantom type by adding a type variable that is not used by its only constructor:

type Button a
= Button Struct
init : Button a
init =
Button { colour = "green", text = "Click me!", icon = Nothing }
withColour : String -> Button a -> Button a
withColour colour (Button struct) =
Button { struct | colour = colour }
withIcon : String -> Button a -> Button a
withIcon icon (Button struct) =
Button { struct | icon = Just icon }

We haven’t changed the implementation of our functions, but we’ve changed everywhere that referenced the Button type to instead accept or produce the phantom type Button a. The phantom type allows us to start putting some constraints on the accepted arguments and return values of our functions:

init : Button { canHaveIcon : () }

This change to init produces a Button with a record that has a canHaveIcon field. The value of that field is simply the unit type, as it isn't useful for us here, but we've tagged the data structure produced by init with some information that we can make use of in other functions. If we wanted to impose more constraints later, we could simply add more fields to the record produced by init.

Let’s look at updating withIcon to check for records with that field:

withIcon : String -> Button { a | canHaveIcon : () } -> Button a
withIcon icon (Button struct) =
Button { struct | icon = Just icon }

Note that this function now only accepts a record that has the canHaveIcon field (using Elm's extensible record syntax), and produces one that can no longer have an icon, as that piece of tagged information is not persisted between the accepted argument and the return value.

Our whole program now looks as follows:

type alias Struct =
{ colour : String
, text : String
, icon : Maybe String
}
type Button a
= Button Struct
init : Button { canHaveIcon : () }
init =
Button { colour = "green", text = "Click me!", icon = Nothing }
withColour : String -> Button a -> Button a
withColour colour (Button struct) =
Button { struct | colour = colour }
withIcon : String -> Button { a | canHaveIcon : () } -> Button a
withIcon icon (Button struct) =
Button { struct | icon = Just icon }

If we test our previous snippet that we were trying to make fail, we now get a compile time error, with a clear message informing the user that they are attempting to configure a Button in an invalid way 🎉

Type error when you attempt to call withIcon on a button that already has an icon

One nice benefit of this technique is that our previous valid examples of configuring buttons continue to compile without any changes, so calling code needs no knowledge of the record type variable that a Button is tagged with.

Summary

The phantom builder pattern technique lets us use Elm’s simple but expressive type system to put powerful constraints on our code, and eliminate some states that should be impossible.

In the examples above we didn’t have to touch the internal implementation of our functions to make these changes, we simply added some hints for the compiler that get translated to hints for users of our modules, helping us to provide a better experience for the developers working with our code.

Further Reading

--

--