Playing With Prisms: For the Not-So-Isomorphic

This blog post is inspired by a Stack Overflow question. And it turns out I made a package for this; You can see the full package here (Elm does not allow publishing packages on platforms other than Microsoft™ GitHub which hamstrings the freedom of all of us).

So in Elm all <input>s, <select>s, & <textarea>s get their value out as a String—we can see that that is the case with Html.Event.targetValue. People will initially get a bit caught when they want to parse an integer from that String.

Do I have my Msg take a String or do Json.Decode the value and then put the Result String Int on the message? Or do I Result.withDefault and just stick a 0 in the Msg??

— My Int-Parsing Comrade

Where this gets a little more confusing and more prone to error is the big, bad <select>. A <select> is basically the UI representation of a union type or ADT. As such we want to display it. The problem is: strings. An Html.option can have an attribute Html.Attributes.value which is a String. Then when an option is selected, we want to turn that String back into our ADT.

Enter the Prism.

type alias Prism a b =
    { getOption : a -> Maybe b
    , reverseGet : b -> a
    }

If we have a quick look, the Prism is a data structure for data transformation that just aren’t quite isomorphic. Some data can be isomorphic like a String.reverse where we can get can go from "paddywhack" to "kcahwyddap" and back again without loss. With an ADT we can easily case out to a String—and the compiler will yell if we don’t, but going back we’re constrained by the fact that the set of all string is infinite. Let’s look at an example:

type Color
    = Red
    | Blue
    | Green

colorFromString : String -> Maybe Color
colorFromString s = case s of
    "red" -> Just Red
    "green" -> Just Green
    "blue" -> Just Blue
    _ -> Nothing

colorToString : Color -> String
colorToString c = case c of
    Red -> "red"
    Green -> "green"
    Blue -> "blue"

We can see plain as day that in the case of colorFromString we are returning a Maybe where the string case falls out. This sure does look a lot like a Prism. getOption is Color -> String and reverseGet is String -> Maybe Color. So let’s make that into Prism then!

{-| You the developer are responsible for this `Prism`s correctness
-}
color_p : Prism String Color
color_p =
    let
        colorFromString : String -> Maybe Color
        colorFromString s = case s of
            "red" -> Just Red
            "green" -> Just Green
            "blue" -> Just Blue
            _ -> Nothing

        colorToString : Color -> String
        colorToString c = case c of
            Red -> "red"
            Green -> "green"
            Blue -> "blue"
    in
        -- Using `Prism` as a constructor
        Prism colorFromString colorToString

Pretty neat, huh? How do we use it?

colorp.reverseGet Red
--=> "red"

colorp.reverseGet Green
--=> "green"

colorp.getOption "red"
--=> Just Red

colorp.getOption "mauve"
--=> Nothing

Hopefully some lightbulbs are going off in your head with this Color -> String -> Color signature-ish thingy. The Prism itself is written generically. Can we stay just as generic for a <select>? You bet we can!

Making a Generic Select

selectp : Prism String a -> (Result String a -> msg) -> a -> List (Attribute msg) -> List ( String, a ) -> Html msg
selectp prism msger selected_ attrs labelValues =
    let
        resultFromString : String -> Result String a
        resultFromString x =
            case prism.getOption x of
                Just y ->
                    Ok y
                _ ->
                    Err ("Failed to get a valid option from " ++ toString x)

        change : Decoder msg
        change =
            Decode.map (resultFromString >> msger) targetValue

        opt : ( String, a ) -> Html msg
        opt ( labl, val ) =
            option
                [ selected (selected_ == val)
                , value (prism.reverseGet val)
                ]
                [ text labl ]
    in
        select (on "change" change :: attrs)
            (List.map opt labelValues)

Prism String a -> (Result String a -> msg) -> a -> List (Attribute msg) -> List ( String, a ) -> Html msg is a pretty intimidating type signature, but it gives us all the levels of abstraction we need. Breaking down the signature one at a time…

  1. Prism from a String to our thing, a

  2. A function from the attempt to get—Result String a, where a is our thing—to a msg for the onChange

  3. The selected value

  4. List of Html.Attributes for the <select> so you can have custom classes, etc.

  5. List tuples of ( String, a ) where the String is the label for the option and the a is our thing.

  6. …Returning some Html.

As far as the code, the onChange is returning us a Result like Http.send so you’re gonna wanna use a message that can handle that Result. Unfortunately, I can’t trust that you’ll implement the getOption part correctly, so it makes absolute sense to return an Err on failure. You can choose to drop it in your update funciton with the message, or you can hold onto it to display an error.

How Does It Look In My View?

import Html exposing (..)

colorOptions : List ( String, Color )
colorOptions =
    [ ( "❤️ Red", Red )
    , ( "💙 Blue", Blue )
    , ( "💚 Green", Green )
    ]

type alias Model =
    { selectedColor : Color }

type Msg
    = ChangeColor (Result String Color)

view : Model -> Html Msg
view { selectedColor } =
    -- Right Here ↓
    selectp colorp ChangeColor selectedColor [] colorOptions

Demo Time!

Includes bonus <select multiple>. Source here.


Takeaway

The Elm meme right now is all about data modeling. Because of that, Prisms hold a good place since ADTs are not comparable (which is why dictionaries return Maybes). They can also help use in other sticky situtations like updating a model that contains an ADT. Since Prisms are optical, they can compose as well. If your ADT contains a record like { foo = True }, we can turn that Prism into an Optional and then compose with a Lens. Check it:

import Monocle.Lens exposing (Lens)
import Monocle.Optional as Optional exposing (Optional)
import Monocle.Prism exposing (Prism)

type alias Foo =
    { foo : Bool }

type Bar
    = Bar Foo

foo_l : Lens Foo Bool
foo_l =
    Lens .foo (\f x -> { x | foo = f })

bar_p : Prism Bar Foo
bar_p =
    Prism (\(Bar b) -> Just b) Bar

barFoo_o : Optional Bar Bool
barFoo_o =
    Optional.composeLens (Optional.fromPrism bar_p) foo_l

x : Bar
x =
    Bar { foo = True }

-- Using

barFoo_o.getOption x
--=> Just True

barFooo.set False x
--=> Bar { foo = False }