Review.PhantomType.forbid
reports choice type
parameters that aren't used in the definition – often called "phantom types".
If you want to learn more about phantom types first, some recommends:
- podcast episode "Phantom Builder Pattern" by elm radio
- talk "The phantom builder pattern" by Jeroen Engels
import Review.Rule
import Review.PhantomType
import NoUnused.CustomTypeConstructors
import NoUnused.CustomTypeConstructorArgs
config : List Review.Rule.Rule
config =
[ Review.PhantomType.forbid
-- to catch variables in unused parts of the type
, NoUnused.CustomTypeConstructors.rule []
, NoUnused.CustomTypeConstructorArgs.rule
]
Claim: "phantom types are hopefully safe, but without the rewards" – worse than even opaque types.
-
phantom types are not simple. How much time would it take you to teach someone extensible phantom record type builders in a way that they could write an API without ways to bypass the types? It certainly risks increasing the burden of entry for users of your API.
-
phantom types are tricky to get right – not great for a type which is supposed to be clear and provide safety. To the untrained eye, they can seem somewhat magical, even. Like relying on overriding field value types in an extended record phantom argument and so on. Worst of all, if you e.g. accidentally provide the wrong phantom type argument (like by misspelling an existing variable), there will be no friendly compiler that has your back.
-
the value does not know as much as your type. If you know opaque types, you know this problem.
type Button constraints = Button { ..., label : Maybe Label } type Label = Text String | Icon Icon type LabelMissing = LabelMissing Never type LabelPresent = LabelPresent Never create : Button LabelMissing withText : String -> (Button LabelMissing -> Button LabelPresent) withIcon : Icon -> (Button LabelMissing -> Button LabelPresent) toHtml : Button LabelPresent -> Html msg toHtml = \button -> ... case button.label of Just (Text text) -> text |> Html.text Just (Icon icon) -> icon |> Icon.toHtml Nothing -> ?? -- welp, this should never happen -- so why do I need to handle this?
compare with e.g.
type Button = Button { ..., label : Label } type Label = Text String | Icon Icon labelled : Label -> Button toHtml : Button -> Html msg toHtml = \button -> ... case button.label of Text text -> text |> Html.text Icon icon -> icon |> Icon.toHtml
It's not about the specific API here, it's about the fact that you can safely read the
label
.
from stupidly obvious to powerful
-
is limiting the choice worth it here? Like, what's the harm in allowing e.g.
- your builder to set a background color after you've already done so
- rules that have no visitors
Maybe you'll also find cases where e.g. having no visitors is useful like with insight rules that only use info from the initial context creator. In any case, just because you can't find a use-case for a value that isn't harmful, why all the complexity to ban it?
-
if you already have a clear idea for the shape of an API and it seems impossible to actualize without phantom types, try asking yourself which parts of the API design are functional and which parts are the "how it looks". You could likely even emulate your idea without phantom types but maybe...
-
can you model the same by adding more choice
type
s? An example based onWebGL.Texture.Resize
-- module WebGL.Texture exposing (Options, Resize, Smaller, Bigger, linear, nearest, nearestMipmapLinear, ...) type alias Options = { ... , magnify : Resize Bigger , minify : Resize Smaller } type Resize scaling = ... type Smaller = Smaller type Bigger = Bigger linear : Resize scaling nearest : Resize scaling nearestMipmapLinear : Resize Smaller
instead, try for example
-- module WebGL.Texture exposing (Options, Magnify(..), Minify(..), ...) type alias Options = { ... , magnify : Magnify , minify : Minify } type Magnify = MagnifyLinear | MagnifyNearest type Minify = MinifyLinear | MinifyNearest | MinifyMipmapLinear
A really good example on how to do this well can be seen in
elm-community/typed-svg
where many types may share some variants like "inherit", "none" and "auto" but in reality, there isn't really one bigger connection uniting all these types.It can make sense to make a type from shared variants in certain contexts. If you can find a name for it, that's a good indicator.
-- module WebGL.Texture exposing (Resize, SimpleResize(..), Minify(..), ...) type alias Options = { ... , magnify : SimpleResize , minify : Minify } type SimpleResize = Linear | Nearest type Minify = MinifySimple SimpleResize | MinifyMipmapLinear
usually though, this is just brain-brain trying to be too clever.
-
can you model the builder differently? Based on the button example from the talk "The phantom builder pattern" by Jeroen Engels
-- module Button exposing (Button, Behaviour, BehaviourMissing, BehaviourPresent, new, withOnClick, withDisabled) type Button constraints msg = Button { ... , behaviour : Behaviour msg } type Behaviour msg = Disabled | OnClick msg type BehaviourMissing = BehaviourMissing Never type BehaviourPresent = BehaviourPresent Never create : Button OnClickOrDisabledMissing msg withDisabled : Button BehaviourMissing msg -> Button BehaviourPresent msg withOnClick : msg -> (Button BehaviourMissing msg -> Button BehaviourPresent msg )
instead, try for example unifying builder helpers that are expected to be called in order
-- module Button exposing (Button, Behaviour(..), create) type Button constraints msg = Button { ... , behaviour : Behaviour msg } type Behaviour msg = Disabled | OnClick msg create : { behaviour : Behaviour msg } -> Button msg
-
model each builder "state" as a separate type. Here's an example slightly similar to
Review.Rule.withModuleVisitor
intoReview.Rule.withModuleContext
type ReviewRuleSchema constraints = ... type ConversionsAndFoldMissing = ConversionsAndFoldMissing Never type ConversionsAndFoldNotMissing = ConversionsAndFoldNotMissing Never withModuleVisitor : ... -> (ReviewRuleSchema ConversionsAndFoldNotMissing -> ReviewRuleSchema ConversionsAndFoldMissing ) withConversionsAndFold : ... -> (ReviewRuleSchema ConversionsAndFoldMissing -> ReviewRuleSchema ConversionsAndFoldNotMissing )
instead, try
type ReviewRuleSchema = ... type ReviewRuleSchemaWithConversionsAndFoldMissing = ... withModuleVisitor : ... -> (ReviewRuleSchema -> ReviewRuleSchemaWithConversionsAndFoldMissing ) withConversionsAndFold : ... -> (ReviewRuleSchemaWithConversionsAndFoldMissing -> ReviewRuleSchema )
obviously this has its limits and is mostly useful if you explicitly need a specific kind of call next. So if you want to use a specific call for different states, you'll need another method.
-
use
Never
to mark certain states as forbidden. Pretty underrated IMO. An example similar toJson.Decode.Attempt
type JsonDecoder parsed recoverable = JsonDecoder (Json.Decode.Value -> Result Error parsed) type Recoverable = Recoverable type Fallible = Fallible decode : JsonDecoder Recoverable parsed -> (Json.Decode.Value -> parsed) decode (JsonDecoder jsonDecode) = \jsonValue -> case jsonValue |> jsonDecode of Ok parsed -> parsed Err _ -> ??? just throw a runtime error I guess jsonValue |> decode (JsonDecoder jsonDecode)
instead, try
type JsonDecoder parsed error = JsonDecoder (Json.Decode.Value -> Result error parsed) decode : JsonDecoder parsed Never -> (Json.Decode.Value -> parsed) decode (JsonDecoder jsonDecode) = \jsonValue -> case jsonValue |> jsonDecode of Ok parsed -> parsed Err ever -> never ever
see
Basics.never
on how this is different from before:Never
is impossible to construct, even internally.It's common that builders require at least 1 call to some helper. Something like
create ... |> and A ... |> and B ... |> and C ...
obviously, you can and should at least consider doing something like
create ... |> andStartWith A ... |> and B ... |> and C ... -- or createAndStartWith ... A ... |> and B ... |> and C ...
but admittedly this can look ugly.
To be able to re-use
and
from both states, we can add a type variable that determines what we know about the builder being "empty": eitherNever
or 🧩Possibly
-- module Enum exposing (Enum, EnumBuilder, ...) type alias Enum value = EnumEmptiable HasMembers value (value -> { name : String, index : Int }) type EnumBuilder constraints value toInfo = EnumEmptiable { toInfo : toInfo , list : List value } type HasNoMembers = HasNoMembers Never type HasMembers = HasMembers Never create : toInfo -> EnumBuilder HasNoMembers value_ toInfo create toInfo = { toInfo = toInfo, list = Emptiable.empty } and : value -> String -> (EnumBuilder constraints_ value ({ name : String, index : Int } -> toInfo) -> EnumBuilder HasMembers value toInfo ) and value name = \enumSoFar -> { toInfo = enumSoFar.toInfo { name = name, index = enumSoFar.list |> Stack.length } , list = enumSoFar.list |> Stack.onTopLay value } randomlyChooseOne : Enum value -> Random.Generator value randomlyChooseOne enum = case enum.list of head :: tail -> Random.uniform head tail [] -> ?? example : Enum Order example = create (\lt eq gt order -> case order of LT -> lt EQ -> eq GT -> gt ) |> and LT "LT" |> and EQ "EQ" |> and GT "GT"
- 🧩
Random
We know the
list
will never be empty but the compiler doesn't. Instead, try-- module Enum exposing (Enum, EnumBuilder(..), ...) type alias Enum value = EnumEmptiable Never value (value -> { name : String, index : Int }) type alias EnumEmptiable emptyPossiblyOrNever value toInfo = { toInfo : toInfo , list : Emptiable (Stacked value) emptyPossiblyOrNever } create : toInfo -> EnumEmptiable Possibly value_ toInfo create toInfo = { toInfo = toInfo, list = Emptiable.empty } and : value -> String -> (EnumEmptiable emptyPossiblyOrNever_ value ({ name : String, index : Int } -> toInfo) -> EnumEmptiable never_ value toInfo ) and value name = \enumSoFar -> { toInfo = enumSoFar.toInfo { name = name, index = enumSoFar.list |> Stack.length } , list = enumSoFar.list |> Stack.onTopLay value } randomlyChooseOne : Enum value -> Random.Generator value randomlyChooseOne enum = Random.uniform (enum.list |> Stack.top) (enum.list |> Stack.removeTop |> Stack.toList) example : Enum Order example = create (\lt eq gt order -> case order of LT -> lt EQ -> eq GT -> gt ) |> and LT "LT" |> and EQ "EQ" |> and GT "GT"
neat, right?
- 🧩 emptiness-typed:
Stack
,Emptiable
- 🧩
-
actually store the phantom type
-- module Quantity exposing (Quantity, Meters, Seconds, ...) type Quantity number units = Quantity number type Meters = Meters meters : number -> Quantity number Meters meters = Quantity toMeters : Quantity number meters -> number toMeters = \(Quantity value) -> value
Found the mistake?
meters
needs to be uppercase.-- module Quantity exposing (Quantity(..), Meters(..), Seconds(..), ...) type Quantity number units = In units number type Meters = Meters meters : number -> Quantity number units meters = In Meters inMeters : Quantity number meters -> number inMeters = -- type mismatch found Meters needs meters \(In Meters value) -> value
This is also cool because you can easily wrap and unwrap quantities without the need for all those
units, toUnits
for every single unit.In : units -> (number -> Quantity number units) to : units -> (Quantity number units -> number)
(a somewhat similar idea and a bit more is published as
elm-typed-value
) -
store both the specific value as well as a function to turn it into a more general type
type Expression = Tuple ( Expression, Expression ) | IntExpression IntExpression | Bool Bool type IntExpression = IntDivideBy IntExpression {-//-} IntExpression | IntLiteral Int type alias ExpressionKnown known = { known : known , toExpression : known -> Expression } tuple : ( ExpressionKnown first, ExpressionKnown second ) -> ExpressionKnown ( first, second ) tuple = \( first, second ) -> { known = ( first.known, second.known ) , toExpression = \( firstSpecific, secondSpecific ) -> ( firstSpecific |> first.toExpression , secondSpecific |> second.toExpression ) } intLiteral : Int -> ExpressionKnown IntExpression intLiteral = \int -> { known = int, toExpression = IntLiteral } intDivideBy : ExpressionKnown IntExpression -> (ExpressionKnown IntExpression -> ExpressionKnown IntExpression ) intDivideBy divisor = \toDivide -> { known = toDivide.known |> IntDivideBy divisor.known , toExpression = IntExpression }
From experience, this only really works when there's a clear "known" and "general" layer. E.g. Once you feel like you have to do
KnownOrGeneral IntLiteral (KnownOrGeneral IntExpression (KnownOrGeneral NumberExpression (KnownOrGeneral ComparableExpression Expression ) ) )
you are doomed. Just keeping only the "known" part and letting users explicitly convert between the types will make things a bit noisier but usually that's fine.
With this many alternatives, are you up for the challenge to try and design your API without phantom types?
I'm super interested in what you're brewing! It's not like I haven't used phantom types for experimental packages as well. If you want to, text me @lue on slack.
Checking for phantom types in types that expand a lot can get expensive. I suggest trying it out in watch mode and if it feels slow with your app, please open an issue. I'm sure we can find edges to optimize.