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

Made with Slides.com