allow/forbid a state at the type level
There are many types that promise non-emptiness. One example: MartinSStewart's NonemptyString
.
fromInt
, char
, ... promise to return a filled string at compile-time.
→ head
, tail
, ... are guaranteed to succeed.
No Maybe
s have to be carried throughout your program. Cool.
How about operations that work on non-empty and emptiable strings, like
length : Text canBeNonEmptyOrEmptiable -> Int
toUpper :
Text canBeNonEmptyOrEmptiable
-> Text canBeNonEmptyOrEmptiable
...
or ones that can pass the (im)possibility of a state from one data structure to the other?
toChars :
Text nonEmptyOrEmptiable
-> Stack Char nonEmptyOrEmptiable
All this is very much possible 🔥
Let's experiment and see where we end up.
type TextThatCanBeEmpty unitOrNever
= TextEmpty unitOrNever
| TextFilled Char String
char : Char -> TextThatCanBeEmpty Never
char onlyChar =
TextFilled onlyChar ""
top : TextThatCanBeEmpty Never -> Char
top =
\text ->
case text of
TextFilled headChar _ ->
headChar
TextEmpty possiblyOrNever ->
possiblyOrNever |> never --! neat
top (char 'E') -- 'E'
top (TextEmpty ()) -- error
→ The type TextThatCanBeEmpty Never
limits arguments to just TextFilled
.
Lets make the type TextThatCanBeEmpty ()/Never
handier:
type TextEmpty possiblyOrNever
type alias Possibly =
()
empty : TextEmpty Possibly
top : TextEmpty Never -> Char
To avoid misuse like empty : Text () Empty
,
we'll represent the ()
tag as a type
:
type Possibly
= Possible
top : Text Never Empty -> Char
empty : Text Possibly Empty
empty =
TextEmpty Possible
👌. Now the fun part: Carrying emptiness-information over:
toChars :
TextEmpty possiblyOrNever
-> StackEmpty possiblyOrNever Char
toChars string =
case string of
TextEmpty possiblyOrNever ->
StackEmpty possiblyOrNever
TextFilled headChar tailString ->
StackFilled headChar (tailString |> String.toList)
so
TextEmpty Never -> StackEmpty Never Char
TextEmpty Possibly -> StackEmpty Possibly Char
I hope you got the idea:
You can allow of forbid a variant by adding a type argument that is either Never
or Possibly
Take a look at data structures that build on this idea. They really make life easier.
Want to know about the phantom builder pattern?
- talk "The phantom builder pattern" by Jeroen Engels
- article "Phantom builder pattern in Elm" by Josh Bebbington
- podcast "Phantom Builder Pattern" in elm-radio
Never
/Possibly
type arguments
cover guarantees the phantom builder pattern can promise, but through narrowing the actual type.
No information lost. The API will always be airtight.
Examples ↓
- at least one builder required
- exactly one builder of a kind required
- duplicate optional builders forbidden
similar to our chosen example:
jfmengels/elm-review
rule visitorsMartinSStewart/elm-serialize
CustomTypeCodec
variants- technically not record phantom builder style. Only one constraint enforced by removing
Let's run with the textAdd
example from the talk "The phantom builder pattern" by Jeroen Engels
type Button event constraints
= Button
{ texts : List String
}
default : Button event {}
textAdd :
String
-> (Button event constraints
-> Button event { constraints | hasText : () }
)
ui :
Button event { constraints | hasText : () }
-> Html event
Here's the same with Never
/Possibly
type arguments
type Button event noTextTag_ noTextPossiblyOrNever
= Button
{ texts : Emptiable (Stacked String) noTextPossiblyOrNever
}
type NoText
= NoTextTag Never
default : Button event NoText Possibly
textAdd :
String
-> (Button event NoText noTextPossiblyOrNever_
-> Button event NoText noTextNever_
)
ui :
Button event NoText Never
-> Html event
Let's run with the interactivity
example from the talk "The phantom builder pattern" by Jeroen Engels
type Button event constraints
= Button
{ interactivity : Maybe (Interactivity event)
}
type Interactivity event
= Disabled
| Clickable event
default : Button event { needsInteractivity : () }
withDisabled :
Button event { constraints | needsInteractivity : () }
-> Button event { constraints | hasInteractivity : () }
ui :
Button event { constraints | hasInteractivity : () }
-> Html event
Here's the same with Never
/Possibly
type arguments
type Button event constraints noInteractivityTag_ noInteractivityPossiblyOrNever
= Button
{ interactivity :
Emptiable (Interactivity event) noInteractivityPossiblyOrNever
}
type Interactivity event
= Disabled
| Clickable event
type NoInteractivity
= NoInteractivityTag Never
default : Button event NoInteractivity Possibly
withDisabled :
Button event NoInteractivity noInteractivityPossiblyOrNever_
-> Button event NoInteractivity noInteractivityNever_
ui :
Button event NoInteractivity Never
-> Html event
example given in
- article "Phantom builder pattern in Elm" by Josh Bebbington
- gist "Phantom Builder Pattern with Elm" by ni-ko-o-kin
default |> 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
.
I'd say that terminology should be consistent to make this clear:
iconAdd
for multiple, iconSet
/withIcon
to override.
Anyway: here's the phantom builder API
type Button constraints
= Button
{ icon : Maybe String
}
default : Button { canHaveIcon : () }
withIcon :
String
-> (Button { constraints | canHaveIcon : () }
-> Button constraints
)
default |> withIcon "arrow-left" |> withIcon "arrow-right"
withIcon "arrow-right"
expectedButton { canHaveIcon : () }
, foundButton {}
Here's the same with Never
/Possibly
type arguments
type Button iconPresentTag_ iconPresentPossiblyOrNever =
Button
{ icon :
Maybe
{ reference : String
, present : iconPresentPossiblyOrNever
}
}
type IconPresent
= IconPresentTag Never
default : Button IconPresent iconPresentNever_
withIcon :
String
-> (Button IconPresent Never
-> Button IconPresent Possibly
)
init |> withIcon "arrow-left" |> withIcon "arrow-right"
--
withIcon "arrow-right"
expectedButton IconPresent Never
, foundButton IconPresent Possibly
As should be obvious by now, having a Button { constraints | hasInteractivity : () }
doesn't actually allow anyone to access what interactivity has been selected,
making it kind of... useless?
Additionally, it's possible to forget to require or provide the right constraints. Misjudging extensible phantom record type behavior can happen – the bad part is: no one will remind you if it's possible to sneak by the constraints – by annotating the builder a certain way, by calling an accidentally exposed constructor, by calling builders in an unanticipated manner, ...
Never
/Possibly
type arguments like in Button NoInteractivity Never
make impossible states impossible – not just unconstructable.
Values can be extracted safely
without any shenanigans like throwing stack-overflows on unexpected representable states.
The compiler will
- warn if constraints aren't enforced
- always infer constraints and promises correctly
One last thing, which you might call "minor": You're allowed to expose the constructor of a type with
Never
/Possibly
type arguments.
Can't do that with phantom-typed values since construction is always "unsafe".
- internal field type changes → record update shortcut unavailable
NoInteractivity Never
is double-negation and harder to read- all constraints a builder doesn't care about have to be listed as variables