The
Phantom Builder Pattern
Elm Online Meetup
October 6th 2021
Jeroen Engels
The
Phantom Builder Pattern
Elm Online Meetup
October 6th 2021
Jeroen Engels
(extensible records)
The
Phantom Builder Pattern
(extensible records)
2
1
3
Elm Online Meetup
October 6th 2021
Jeroen Engels
- Builder pattern
Pattern for creating configurable elements
1
Usage
view : Html Msg
view =
Button.new
|> Button.withText "Submit"
|> Button.withPrimaryStyle
|> Button.withOnClick UserClickedSubmitButton
|> Button.toHtmlContrast with the Html-pattern
button : Html Msg
button =
Button.new
|> Button.withText "Submit"
|> Button.withPrimaryStyle
|> Button.withOnClick UserClickedSubmitButton
|> Button.toHtmlbutton : Html Msg
button =
Button.button
[ Button.text "Submit"
, Button.primaryStyle
, Button.onClick UserClickedSubmitButton
]Implementation
module Button exposing
( Button, new
, withOnClick, withIcon {- and more -}
, toHtml)
type Button msg =
Button
{ text : String
, onClick : Maybe msg
-- ...
}
withOnClick : msg -> Button msg -> Button msg
withOnClick onClick (Button button) =
Button { button | onClick = Just onClick }view : Html Msg
view =
Button.new
|> Button.withText "Click me!"
-- We don't want a button that is both
-- clickable
|> Button.withOnClick ButtonHasBeenClicked
-- AND disabled
|> Button.withDisabled
|> Button.toHtmlProblem: unwanted combinations
type Maybe a
= Just a
| Nothing
name : Maybe String
name = Just "Jeroen"
age : Maybe Int
age = Just 31Different data
Same structure
Type variables
Same data
Same structure
- Phantom types
-- a is not used
type Currency a
= Currency Int
type Dollar = Dollar
type Euro = Euro
dollars : Currency Dollar
dollars = Currency 100
euros : Currency Euro
euros = Currency 200
-- TYPE MISMATCH ❌
euros === dollarsYet not the same type
Useful to differentiate similar data
2
Same data ✅
Same structure ✅
What about our Button type...?
clickableButton : msg -> Button msg
clickableButton onClick =
Button.new
|> Button.withOnClick onClick
disabledButton : Button msg
disabledButton =
Button.new
|> Button.withDisabledNo way to differentiate ❌
Button with phantom type
type Button constraints msg =
Button
{ text : String
-- ...
}
Button with phantom type
type Not_DisabledOrClickable
= Not_DisabledOrClickable Never
new : Button Not_DisabledOrClickable msg
new =
-- Unchanged compared to before
Button
{ text = ""
-- ...
}Button with phantom type
type DisabledOrClickable
= DisabledOrClickable Never
withDisabled :
Button Not_DisabledOrClickable msg
-> Button DisabledOrClickable msg
withDisabled (Button button) =
-- Unchanged compared to before
Button { button | disabled = True }
withOnClick :
msg
-> Button Not_DisabledOrClickable msg
-> Button DisabledOrClickable msg
withOnClick onClick (Button button) = -- ...We made a state machine!
new : Button Not_DisabledOrClickable msg
withDisabled :
Button Not_DisabledOrClickable msg
-> Button DisabledOrClickable msgProblem solved!

What's the constraint on toHtml?
Button DisabledOrClickable msg -> Html msgButton constraints msg -> Html msgview =
Button.new
-- UNFORTUNATELY COMPILES ❌
-- |> Button.withText "Click me!"
|> Button.toHtmlNew problem: text is missing
view =
Button.new { text = "Click me!" }
|> Button.toHtmlSolution:
Move mandatory argument to `new`
buttonWithIcon =
Button.new { text = "" }
|> Button.withoutText -- 😢
|> Button.withIcon Icons.SaveIcon
|> Button.toHtmlButton with only an icon?
type NoTextOrIcon = NoTextOrIcon Never
type HasTextOrIcon = HasTextOrIcon Never
new : Button NoTextOrIcon msg
withText : Button constraints msg -> Button HasTextOrIcon msg
withIcon : Button constraints msg -> Button HasTextOrIcon msg
toHtml : Button HasTextOrIcon msg -> Html msgRequire text and/or icon?
Great!
but...
we lost the onClick/disabled constraints...
If only we could extend these constraints.
- Extensible records
incrementX : { a | x : Int } -> { a | x : Int }
incrementX record =
{ record | x = record.x + 1 }
doSomething : { a | x : Int } -> { a | notX : Int }
doSomething record =
-- Impossible implementation in Elm3
Extensible records in phantom types
with... : Button { a | x : Int } msg -> Button { a | notX : Int } msg
with... (Button button) =
-- No problem ✅
Button buttonCapabilities
-- Remove a field
Button { a | x : () } -> Button a
-- Add a field
Button a -> Button { a | x : () }
-- Change the type of a field
Button { a | x : Y } -> Button { a | x : Z }
-- Reset the record
Button a -> Button {}The phantom builder pattern
new : Button { needsInteractivity : () } msg
withDisabled :
Button { constraints | needsInteractivity : () } msg
-> Button { constraints | hasInteractivity : () } msg
withText :
String
-> Button constraints msg
-> Button { constraints | hasTextOrIcon : () } msg
toHtml :
Button { constraints | hasInteractivity : (), hasTextOrIcon : () } msg
-> Html msgextensible records
🎉
🎉
Basic constraints
- Require a function to always be called.
- Forbidding calling the same function twice.
- Based on whether something else was called:
- Require a function to always be called
- Enable a function to be called
- Prevent a function from being called
Super extensible, super configurable
No runtime cost
Looks like a regular builder pattern
button : Html Msg
button =
Button.new
|> Button.withText "Submit"
|> Button.withPrimaryStyle
|> Button.withOnClick UserClickedSubmitButton
|> Button.toHtmlChanging constraints doesn't require changing how the code looks
Drawbacks
Putting multiple items in a collection
listOfButtons : List (Button ??? msg)
listOfButtons =
[ Button.new
, Button.new
|> Button.withDisabled
]Putting multiple items in a collection
listOfButtons : List (Html msg)
listOfButtons =
[ Button.new
|> Button.toHtml
, Button.new
|> Button.disabled
|> Button.toHtml
]
Conditionals
createButton disabled =
Button.new
|> ( -- TYPE MISMATCH
if disabled then
-- Button { a | interactive : () } msg
-- -> Button a msg
Button.withDisabled
else
-- a -> a
identity
)Conditionals
createButton disabled =
Button.new
|> ( -- TYPE MATCH ✅
if disabled then
Button.withDisabled
else
Button.withOnClick ButtonClicked
)All constraint changes are breaking changes
This is a MAJOR change.
---- Button - MAJOR ----
Changed:
- withOnClick :
msg
-> Button { constraint | x : () } msg
-> Button constraint msg
- withOnClick :
msg
-> Button constraint msg
-> Button constraint msgAPI needs to be airtight
Design needs quite some thought
Testing constraints
Go make awesome things!
@jfmengels
Jeroen Engels
https://slides.com/jfmengels/phantom-builder-pattern
jfmengels.net
The Phantom Builder Pattern
By Jeroen Engels
The Phantom Builder Pattern
- 636