Phantom builder pattern in Elm
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 Stringinit : 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 Structinit : 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 aMaybe 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 Structinit : 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
= NotValidatedtype Validated
= ValidatednewAddress : Address NotValidated
newAddress =
-- Construct an address, not validated by defaultvalidateAddress : Address NotValidated -> Address Validated
validateAddress address =
-- Accept an un-validated address, perform any validations, and produce a valid addresssaveAddress : 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 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 Structinit : 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 Structinit : 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 🎉
withIcon
on a button that already has an iconOne 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
- [1] All credit for the ‘phantom builder pattern’ term goes to Jeroen Engels, who first shared it on an Incremental Elm livestream.
- [2] https://thoughtbot.com/blog/modeling-currency-in-elm-using-phantom-types
- [3] https://medium.com/@ckoster22/advanced-types-in-elm-phantom-types-808044c5946d