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.toHtml
Contrast with the Html-pattern
button : Html Msg
button =
Button.new
|> Button.withText "Submit"
|> Button.withPrimaryStyle
|> Button.withOnClick UserClickedSubmitButton
|> Button.toHtml
button : 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.toHtml
Problem: unwanted combinations
type Maybe a
= Just a
| Nothing
name : Maybe String
name = Just "Jeroen"
age : Maybe Int
age = Just 31
Different 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 === dollars
Yet 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.withDisabled
No 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 msg
Problem solved!
What's the constraint on toHtml?
Button DisabledOrClickable msg -> Html msg
Button constraints msg -> Html msg
view =
Button.new
-- UNFORTUNATELY COMPILES ❌
-- |> Button.withText "Click me!"
|> Button.toHtml
New problem: text is missing
view =
Button.new { text = "Click me!" }
|> Button.toHtml
Solution:
Move mandatory argument to `new`
buttonWithIcon =
Button.new { text = "" }
|> Button.withoutText -- 😢
|> Button.withIcon Icons.SaveIcon
|> Button.toHtml
Button 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 msg
Require 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 Elm
3
Extensible records in phantom types
with... : Button { a | x : Int } msg -> Button { a | notX : Int } msg
with... (Button button) =
-- No problem ✅
Button button
Capabilities
-- 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 msg
extensible 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.toHtml
Changing 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 msg
API 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
- 464