I’m still on the hunt for “the right” programming language for web front-ends. JavaScript is fun and very good for quickly hacking together something, but as soon as your project grows you either need a large number of tests and discipline or your going to break something with every refactoring. TypeScript seemed like a good rescue - but coming from Haskell I have high standards for type systems and the TypeScript one still has loop holes. The other problem with both languages is, that you are responsible for managing and syncing your state and model correctly. React and other frameworks help you with this, but you still have to use them correctly and there’s always a way to sneak around. Elm to the rescue? Let’s see!

Warning: the recent Elm Version does things a bit differently, so this walk-through will not work anymore

Diving into Elm

If you’re not new to Elm, jump to section ‘Real world Elm’

A typical Elm program is divided into three parts: view, model and update.

The model

The model defines your local state

type Operation
   = AddOp
   | SubOp

type alias Model =
    { displayed : Int
    , lastResult : Int
    , op : Operation
    }

The update

The update part defines a pure function running an action on your model

type Action
   = PressNumber Int
   | Add
   | Subtract
   | Result
   | Clear
   | ClearAll

update : Action -> Model -> Model
update action model =
    case action of
        PressNumber i ->
            let (Ok newNumber) = String.toInt (toString model.displayed ++ toString i)
            in { model | displayed <- newNumber }
        Add ->
            { model | displayed <- 0, lastResult <- model.displayed, op <- AddOp }
        -- ...

The view

The view is just a pure function converting your model into something “renderable” like HTML or a canvas image.

header : Html
header =
    div' { class = "header clearfix "}
    [ nav_  []
    , h3' { class = "text-muted" } [ text "Elm Calculator" ]
    ]

view : Signal.Address Action -> Model -> Html
view address model =
    let numBtn i =
            colXs_ 3 [ btnDefault_ { btnParam | label <- Just (toString i) } address (PressNumber i) ]
        opBtn desc op =
            colXs_ 3 [ btnPrimary_ { btnParam | label <- Just desc } address op ]
    in container_
        [ header
        , div' { class = "result" } [ text (toString model.displayed) ]
        , div' { class = "num-pad "}
           [ row_ [ numBtn 1, numBtn 2, numBtn 3, opBtn "+" Add ]
           , row_ [ numBtn 4, numBtn 5, numBtn 6, opBtn "-" Subtract ]
           , row_ [ numBtn 7, numBtn 8, numBtn 9, opBtn "=" Result ]
           , row_ [ opBtn "Clear" Clear, opBtn "Clear All" ClearAll ]
           ]
        ]

It’s really just that simple. And it comes with some cool advantages: Your update function is pure! This means you can test is very easily. There’s a simple package to wire all this together called start-app.

Wiring it all up

main =
    StartApp.start
    { model = init
    , update = update
    , view = view
    }

That’s all that’s needed for a tiny calculator. I left out the “boring” parts like imports and the full update implementation, but the full code for this calculator is available.

Coming from Haskell implementing this was pretty quick and fun. The only major issue I came across was that it’s quite hard to explore the Elm ecosystem. The types in the documentation are not yet hyperlinked, so it’s difficult to trace down what comes from where and how everything should work together.

Real world Elm

The next logical step for me was to try out Elm in the real world. The frontend of TramCloud currently heavily relies on React and JavaScript; but it’s also very modular so I decided to implement a new component using Elm. I first created a new module to access the backend API:

module Lib.Api where

import Http
import Task exposing (..)
import Json.Decode as Json exposing ((:=))

type alias ReferenceProfileId = Int

type alias ReferenceProfile =
    { id: ReferenceProfileId
    , name : String
    , notes : String
    , measurePointOffset : Float
    , profile : RawMeasurement
    }

referenceProfileDec : Json.Decoder ReferenceProfile
referenceProfileDec =
    Json.object5 ReferenceProfile
        ("id" := Json.int)
        ("name" := Json.string)
        ("notes" := Json.string)
        ("measurePointOffset" := Json.float)
        ("data" := rawMeasurementDec)

-- ...

listReferenceProfiles : Task Http.Error (List (ReferenceProfile))
listReferenceProfiles =
    Http.get (Json.list referenceProfileDec) "/api/referenceprofile/list"

deleteReferenceProfile : ReferenceProfileId -> Task Http.Error Bool
deleteReferenceProfile rpid =
    let bdy = Http.multipart [ Http.stringData "profile-id" (toString rpid) ]
    in Http.post Json.bool "/api/referenceprofile/delete" bdy

-- (don't worry - the endpoints are fake)

The Json.Decoder needs to match our Haskell aeson instance for the type, and I don’t think writing all this by hand is a good idea. But I’ve already got something planned to automatically generate an Elm API module from Haskell using Spock and highjson. Stay tuned for that ;-)

Now we need to write the actual logic working with these ReferenceProfiles. We would like to define a table to display them and allow actions like modifying and deleting. This means jumping through the same steps as before: model, view and update:

The model

The model is just the list of ReferenceProfiles:

type alias Model =
    { list : List Api.ReferenceProfile
    }

initModel =
    { list = []
    }

The update

The update part is a little bit more complex, as we want optimistic UI. Let’s define our actions first:

type ServerMessage
    = ProfileList (List (Api.ReferenceProfile))
type ServerQuery
    = SqRefreshProfiles
    | SqDeleteProfile Api.ReferenceProfileId
    | SqNoop
type Action
    = ServerAction ServerMessage
    | ClientAction ServerQuery

The ServerQuery are the possible queries that can be sent to the server. The ServerMessage are the possible responses. The Actions on the model are a combination of the queries and the responses. This is important, as that’s used to implement optimistic UI.

Now wee need several Mailboxes to manage queries and responses:

serverResults : Signal.Mailbox (Maybe ServerMessage)
serverResults =
    Signal.mailbox Nothing

serverQuery : Signal.Mailbox (Maybe ServerQuery)
serverQuery =
    Signal.mailbox Nothing

These will be wired together using a port:

runQuery : ServerQuery -> Task Http.Error (Maybe ServerMessage)
runQuery q =
    case q of
        SqRefreshProfiles ->
            Task.map (Just << ProfileList) Api.listReferenceProfiles
        SqDeleteProfile pid ->
            Task.map (Just << ProfileList) (Api.deleteReferenceProfile pid `Task.andThen` \_ -> Api.listReferenceProfiles)
        SqNoop ->
            Task.succeed Nothing

port apiRequestPort : Signal (Task Http.Error ())
port apiRequestPort =
    serverQuery.signal
    |> Signal.filterMap identity SqRefreshProfiles
    |> Signal.map runQuery
    |> Signal.map (\task -> task `Task.andThen` Signal.send serverResults.address)

You can think of a port as task runner for Tasks that need to interface with the outside world. Note the default SqRefreshProfiles in filterMap to load everything on app launch once. Now we are ready to define our update function:

update : Action -> Model -> Model
update a m =
    case a of
        ServerAction (ProfileList l) -> { m | list <- l }
        ClientAction SqNoop -> m
        ClientAction SqRefreshProfiles -> m
        ClientAction (SqDeleteProfile x) -> { m | list <- List.filter (\p -> p.id /= x) m.list }

Nothing surprising here, just applying the actions to our model.

The view

Just rendering the Model to Html and wiring our Address to our button(s).

view : Signal.Address ServerQuery -> Model -> Html
view addr m =
    div_
    [ div' { class = "page-header" } [ h1_ "Reference profiles: Catalog" ]
    , tableStriped_
        [ thead_
            [ Html.th [ Html.style [ ("width", "100px") ] ] [ text "profile" ]
            , Html.th [ Html.style [ ("width", "100px") ] ] []
            , th_ [ text "name" ]
            , th_ [ text "measurepoint "]
            , th_ [ text "description "]
            , th_ []
            ]
        , tbody_ (List.map (referenceProfileRow addr) m.list)
        ]
    ]

referenceProfileRow : Signal.Address ServerQuery -> Api.ReferenceProfile -> Html
referenceProfileRow addr rp =
    let buttons =
            [ btnSmDefault_
                    { btnParam
                        | label <- Just "delete"
                        , icon <- Just glyphiconTrash_
                    } addr (SqDeleteProfile rp.id)
            ]
    in tr_
        [ td_ [ Rp.renderProfile 100 100 rp.profile.leftData ]
        , td_ [ Rp.renderProfile 100 100 rp.profile.rightData ]
        , td_ [ text rp.name ]
        , td_ [ text (toString rp.measurePointOffset ++ " mm") ]
        , td_ [ text rp.notes ]
        , td_ buttons
        ]

Wire it up

Now we need to connect everything:

  • Signals coming from the UI and the Server should go into our update function and fold over our Model
  • Signals coming from the UI should trigger custom logic that may send HTTP requests (serverQuery mailbox)
  • The UI should not be able to send an empty (Nothing) signal.
main : Signal Html
main =
    let address = Signal.forwardTo serverQuery.address Just
        serverSignals : Signal Action
        serverSignals =
            Signal.filterMap (Maybe.map ServerAction) (ServerAction (ProfileList [])) serverResults.signal
        clientSignals : Signal Action
        clientSignals =
            Signal.filterMap (Maybe.map ClientAction) (ClientAction SqNoop) serverQuery.signal
        model =
            Signal.foldp (\action model -> update action model) initModel (Signal.merge clientSignals serverSignals)
    in Signal.map (view address) model

That’s it! Optimistic UI, talking to a Rest API, a good looking UI and maintainable code. All this took a day to figure out and once it typechecked it worked. Cool! I can not draw any final conclusion on Elm yet (have not used it enough), but it looks very promising. I will continue to use it for now.

Comments

Looking forward to your Feedback on Reddit and HackerNews.