Inferring subtype of custom type

Hi, folks. I’m trying to work out the idiomatic Elm-way of accomplishing this task. I have a case in an update function to which I’d like to pass another function. This passed function is a setter on a record:

update msg model =
    case msg of
        SetLatLon assign s ->
            let
                oldCoord =
                    case model.location of
                        Nothing -> {lat = 0.0, lon = 0.0}
                        Just c -> c
                f = String.toFloat s |> Maybe.withDefault 0.0
                newCoord = assign oldCoord f
                loc = if isEmpty s then Nothing else Just newCoord
            in
                ({model | location = loc}, Cmd.none)
...

The Coord type is an alias for {lat: Float, lon: Float} and model.location is a Maybe Coord.

This SetLatLon message is being invoked from view in this manner:

                     , input
                          [ type_ "text"
                          , title "Latitude"
                          , SetLatLon (\c f -> {c | lat = f}) |> onChange
                          ] []
                     , input
                          [ type_ "text"
                          , title "Longitude"
                          , SetLatLon (\c f -> {c | lon = f}) |> onChange
                          ] []

My puzzle is how to define the Msg custom type. What I have that’s working (after considerable tinkering) is to declare another alias:

type alias Setter b a = { b | lat : a } -> a -> { b | lat : a }

and then define Msg as:

type Msg =
      | ...
      | SetLatLon (Setter Coord Float) String

Something bothers me about having to explicitly specify lat in the Setter definition, yet I’m still passing in a function that sets either lat or lon. I’d like to be explicit in the Setter alias that a Coord is expected rather than simply a type of record having a lat member. It would also be nice to be able to omit the explicit Setter alias altogether and just define the SetLatLon subtype using the plain type definition of the passed function. I haven’t figured out how to do either of these things, which leads me to suspect I’m going about this in a suboptimal, non-idiomatic way.

I appreciate any tips on how I could approach this differently or could approach it the same way but with clearer syntax.

Tried changing it to:

type alias Setter b a =
    b -> a -> b

and it worked.

Or don’t bother with the type alias and put it directly in the Msg constructor:

type Msg = 
    | ...
    | SetLatLon (Coord -> Float -> Coord) String

Surprising your code is able to compile with:

type alias Setter b a = { b | lat : a } -> a -> { b | lat : a }

...
, SetLatLon (\c f -> {c | lon = f}) |> onChange

But perhaps the compiler infers that c must have a lat field from the SetLatLon type, but is also happy since that is an extensible type that it can also have a lon field.

Ah! And then that enables me to declare SetLatLon as:

      | SetLatLon (Coord -> Float -> Coord) String

and omit the Setter entirely.

Thanks so much! That seems so much tidier.

I think I know why it type checks:

-- You define Setter over an extensible type with params a and b
type alias Setter b a =
    { b | lat : a } -> a -> { b | lat : a }

-- This is the concrete Coord type, not extensible but does satisfy { b | lat : Float }
type alias Coord =
    { lat : Float, lon : Float }

-- Here you give Setter the Coord type as argument a.
type Msg
    = SetLatLon (Setter Coord Float) String

In the last one where you give Setter the Coord type as argument a, you are instantiating the constructor with the Setter to have the type Coord -> Float -> Coord. So it all works out right.

Nice trick with polymorphic updates, I think this has been posted on here before somewhere. Shame about the function in Msg type though - it works just bordering on bad practice.

I see. That makes sense that it ends up satisfying the type constraint by calling the Setter constructor with specific values. I only stumbled across the existence of extensible types in the process of trying to sort out this issue and am still trying to wrap my head around them. I was surprised to see that Elm essentially supports what is effectively type-checked duck typing.

The way I came up with that initial overly-generic declaration for the Setter alias was going by what the repl reported when I declared that anonymous function. Now I see I didn’t need that level of genericness. I think I just wasn’t thinking clearly enough about the fact that regardless of what was being done within the anonymous function, ultimately I was just passing in a Coord and a Float to get back a Float.

Yes, I suppose that is what an extensible type is - so long as this thing has such and such properties, we don’t care what the rest of it is. Strictly speaking, I think duck typing means that this only has to hold true at runtime, but in Elm with extensible records this is checked at compile time.

Note though, that it is not the same as subtyping. I mention this as the title of the topic has the word ‘subtype’ in it.

Right. In Elm parlance I think the term I was searching for is “variant” rather than “subtype.”

Just now noting your earlier comment about it bordering on being a bad practice to define a Msg variant as having a function parameter. What is it that’s undesirable about that?

Generally, we try to avoid putting functions in Msg or Model but there is really nothing in Elm or TEA that stops you doing it, and it will work just fine. Sometimes there is some neat trick like this that is just too tempting to ignore.

The downsides are:

  1. If you were running the debugger and exported the debug session, as functions cannot be serialised the output would not be something that could be used to recreate the application state. Similarly if you logged the msg to the console you would not get something that tells you what function was being applied.

  2. From the perspective of someone reading your update logic, some function is applied to the model to update it, which does not make it obvious what is actually being done to the model. The code would be more explicit with individual messages for updating lat and updating lon. You traded off boiler plate code against being explicit.

Imagine some application where all updates to the model are done with functions:

type alias Model = { ... }

type alias Msg = 
   Model -> Model

update : Msg -> Model -> (Model, Cmd Msg)
update msg model = 
   (msg model,
    Cmd.none) -- No side effects in this example.

Now all your update logic is in the view.

1 Like

This latter point is especially compelling. I initially implemented this functionality by defining another custom type (LatLon = Lat | Lon) and passing one of its variants in as a parameter to the update message, using a case to build up the new Coord accordingly. I didn’t like the verbosity of creating a whole new type just for that one purpose, but I hadn’t really accounted for the tradeoff I was making in terms of consistency in where business decisions are being made. Thanks for the clarification.

type LatLon = Lat | Lon

type Msg
    = UpdateField LatLon Float
    | ...

Is really just a nested version of:

type Msg
    = UpdateLat Float
    | UpdateLon Float
    | ...

BTW, there is a technique by which data models in Elm can be simplified by math. A custom type behaves like + (plus), and record types behave like * (multiplication).

latlon = lat + lon
msg = latlon * float + x

msg = lat * float + lon * float + x

Following that guidance, this moves the logic back into update, incurring a minimal amount of redundancy and without introducing a new union type for a predicate:

--- a/src/Main.elm
+++ b/src/Main.elm
@@ -89,7 +89,8 @@ type Msg
       | GetLocalZone Time.ZoneName
       | RequestGeo
       | ReceiveGeo (Maybe Coord)
-      | SetLatLon (Coord -> Float -> Coord) String
+      | SetLat String
+      | SetLon String
       | IsoDateChange String
       | TemplateChange String
       | SetTimeZone String
@@ -99,6 +100,18 @@ type Msg
 
 update : Msg -> Model -> (Model, Cmd Msg)
 update msg model =
+    let
+        setLatLon assign s =
+            let
+                oldCoord =
+                    case model.location of
+                        Nothing -> {lat = 0.0, lon = 0.0}
+                        Just c -> c
+                f = String.toFloat s |> Maybe.withDefault 0.0
+                newCoord = assign oldCoord f
+            in
+            if isEmpty s then Nothing else Just newCoord
+    in
     case msg of
         SubmitRequest ->
             (model, getThisDate model)
@@ -133,17 +146,11 @@ update msg model =
             in
                 (newmod, getThisDate newmod)
 
-        SetLatLon assign s ->
-            let
-                oldCoord =
-                    case model.location of
-                        Nothing -> {lat = 0.0, lon = 0.0}
-                        Just c -> c
-                f = String.toFloat s |> Maybe.withDefault 0.0
-                newCoord = assign oldCoord f
-                loc = if isEmpty s then Nothing else Just newCoord
-            in
-                ({model | location = loc}, Cmd.none)
+        SetLat s ->
+            ({model | location = setLatLon (\c f -> {c | lat = f}) s}, Cmd.none)
+
+        SetLon s ->
+            ({model | location = setLatLon (\c f -> {c | lon = f}) s}, Cmd.none)
 
         IsoDateChange isoDate ->
             ( {model | isoDate = isoDate}, Cmd.none)
@@ -268,15 +275,15 @@ formDisplay model =
                           , title "Latitude"
                           , placeholder "45.1023"
                           , mayFloat (.lat) |> value
-                          , SetLatLon (\c f -> {c | lat = f}) |> onChange
+                          , SetLat |> onChange
                           ] []
                     , label [] [text "Lon: "]
                     , input
                           [ type_ "text"
                           , title "Longitude"
                           , placeholder "-122.4180"
                           , mayFloat (.lon) |> value
-                          , SetLatLon (\c f -> {c | lon = f}) |> onChange
+                          , SetLon |> onChange
                           ] []
                     ]
 

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.