DEV Community

Dwayne Crooks
Dwayne Crooks

Posted on • Updated on

Separating application logic from the UI in Elm

Yesterday I started to implement the logic for a simple calculator web app that I'm recreating.

A simple calculator

One of my goals in building it is to use the features of Elm to cleanly separate the application logic from the UI.

I believe I achieved what I was going for and that's why I'm so excited to share my progress with you.

So what did I do?

I made a data structure in a Calculator module with the following public API:

module Calculator exposing
  ( Calculator, Key(..), Operator(..)
  , new, process

  , Display
  , toDisplay
  )
Enter fullscreen mode Exit fullscreen mode

N.B. The implementation details aren't important right now.

Calculator is an opaque type.

The new function creates a Calculator. process takes a Key and a Calculator and returns an updated Calculator. And, toDisplay allows you to observe the results produced by a given Calculator.

For e.g.

Calculator.new
  |> Calculator.process (Digit 1)
  |> Calculator.process (Digit 2)
  |> Calculator.process (Operator Plus)
  |> Calculator.process (Digit 3)
  |> Calculator.toDisplay

{ expr = "12+3", output = "3" }
Enter fullscreen mode Exit fullscreen mode

I experienced two major benefits. The first was that I was able to unit test (yes, testing is important even in a typed functional language) my logic and gain confidence in my implementation.

Here are the tests:

module Test.Calculator exposing (suite)


import Expect
import Test exposing (..)

import Calculator exposing (Key(..), Operator(..))


suite : Test
suite =
  describe "Calculator"
    [ processSuite ]


processSuite : Test
processSuite =
  describe "process"
    [ describe "when nothing has been entered" <|
        let
          calculator =
            Calculator.new
        in
          [ test "pressing AC" <|
              \_ ->
                calculator
                  |> Calculator.process AC
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "", output = "0" }
          , test "pressing a digit" <|
              \_ ->
                calculator
                  |> Calculator.process (Digit 1)
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "1", output = "1" }
          , test "pressing an operator" <|
              \_ ->
                calculator
                  |> Calculator.process (Operator Plus)
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "", output = "0" }
          , test "pressing =" <|
              \_ ->
                calculator
                  |> Calculator.process Equal
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "", output = "0" }
          ]
    , describe "when a number has been entered" <|
        let
          calculator =
            Calculator.new
              |> Calculator.process (Digit 1)
              |> Calculator.process (Digit 2)
        in
          [ test "pressing AC" <|
              \_ ->
                calculator
                  |> Calculator.process AC
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "", output = "0" }
          , test "pressing a digit" <|
              \_ ->
                calculator
                  |> Calculator.process (Digit 3)
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "123", output = "123" }
          , test "pressing an operator" <|
              \_ ->
                calculator
                  |> Calculator.process (Operator Plus)
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "12+", output = "+" }
          , test "pressing =" <|
              \_ ->
                calculator
                  |> Calculator.process Equal
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "12=12", output = "12" }
          ]
    , describe "when a number and operator has been entered" <|
        let
          calculator =
            Calculator.new
              |> Calculator.process (Digit 1)
              |> Calculator.process (Digit 2)
              |> Calculator.process (Operator Plus)
        in
          [ test "pressing AC" <|
              \_ ->
                calculator
                  |> Calculator.process AC
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "", output = "0" }
          , test "pressing a digit" <|
              \_ ->
                calculator
                  |> Calculator.process (Digit 3)
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "12+3", output = "3" }
          , test "pressing an operator" <|
              \_ ->
                calculator
                  |> Calculator.process (Operator Minus)
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "12-", output = "-" }
          , test "pressing =" <|
              \_ ->
                calculator
                  |> Calculator.process Equal
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "12=12", output = "12" }
          ]
    , describe "when a complete expression has been entered" <|
        let
          calculator =
            Calculator.new
              |> Calculator.process (Digit 1)
              |> Calculator.process (Digit 2)
              |> Calculator.process (Operator Plus)
              |> Calculator.process (Digit 3)
              |> Calculator.process (Operator Minus)
              |> Calculator.process (Digit 4)
        in
          [ test "pressing AC" <|
              \_ ->
                calculator
                  |> Calculator.process AC
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "", output = "0" }
          , test "pressing a digit" <|
              \_ ->
                calculator
                  |> Calculator.process (Digit 5)
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "12+3-45", output = "45" }
          , test "pressing an operator" <|
              \_ ->
                calculator
                  |> Calculator.process (Operator Plus)
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "12+3-4+", output = "+" }
          , test "pressing =" <|
              \_ ->
                calculator
                  |> Calculator.process Equal
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "12+3-4=11", output = "11" }
          ]
    , describe "when an answer is given" <|
        let
          calculator =
            Calculator.new
              |> Calculator.process (Digit 1)
              |> Calculator.process (Digit 2)
              |> Calculator.process (Operator Plus)
              |> Calculator.process (Digit 3)
              |> Calculator.process (Operator Minus)
              |> Calculator.process (Digit 4)
              |> Calculator.process Equal
        in
          [ test "pressing AC" <|
              \_ ->
                calculator
                  |> Calculator.process AC
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "", output = "0" }
          , test "pressing a digit" <|
              \_ ->
                calculator
                  |> Calculator.process (Digit 9)
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "9", output = "9" }
          , test "pressing an operator" <|
              \_ ->
                calculator
                  |> Calculator.process (Operator Plus)
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "11+", output = "+" }
          , test "pressing =" <|
              \_ ->
                calculator
                  |> Calculator.process Equal
                  |> Calculator.toDisplay
                  |> Expect.equal { expr = "12+3-4=11", output = "11" }
          ]
    ]
Enter fullscreen mode Exit fullscreen mode

The second benefit was how trivial it became to implement the UI. I see many web apps that write extensive integration tests for UI code but that's because they couple the UI with the application logic. With proper separation of concerns you'd end up with more unit tests and much less integration tests (if any).

Here's the code that makes the UI work:

-- MODEL


type alias Model =
  { calculator : Calculator
  }


init : Model
init =
  { calculator = Calculator.new
  }


-- UPDATE


type Msg
  = Clicked Key


update : Msg -> Model -> Model
update msg model =
  case msg of
    Clicked key ->
      { model | calculator = Calculator.process key model.calculator }


-- VIEW


viewCalculator : Calculator -> Html Msg
viewCalculator calculator =
  let
    display =
      Calculator.toDisplay calculator
  in
    div [ class "calculator" ]
      [ div [ class "calculator__expr" ]
          [ if String.isEmpty display.expr then
              text (String.fromChar nonBreakingSpace)
            else
              text display.expr
          ]
      , div [ class "calculator__output" ] [ text display.output ]
      , div [ class "calculator__buttons" ]
          [ button [ class "r0 c0 colspan2 bg-red", onClick (Clicked AC) ]
              [ text "AC" ]
          , button [ class "r0 c2", disabled True ]
              [ text "รท" ]
          , button [ class "r0 c3", onClick (Clicked (Operator Times)) ]
              [ text "ร—" ]
          , button [ class "r1 c0", onClick (Clicked (Digit 7)) ]
              [ text "7" ]
          , button [ class "r1 c1", onClick (Clicked (Digit 8)) ]
              [ text "8" ]
          , button [ class "r1 c2", onClick (Clicked (Digit 9)) ]
              [ text "9" ]
          , button [ class "r1 c3", onClick (Clicked (Operator Minus)) ]
              [ text "-" ]
          , button [ class "r2 c0", onClick (Clicked (Digit 4)) ]
              [ text "4" ]
          , button [ class "r2 c1", onClick (Clicked (Digit 5)) ]
              [ text "5" ]
          , button [ class "r2 c2", onClick (Clicked (Digit 6)) ]
              [ text "6" ]
          , button [ class "r2 c3", onClick (Clicked (Operator Plus)) ]
              [ text "+" ]
          , button [ class "r3 c0", onClick (Clicked (Digit 1)) ]
              [ text "1" ]
          , button [ class "r3 c1", onClick (Clicked (Digit 2)) ]
              [ text "2" ]
          , button [ class "r3 c2", onClick (Clicked (Digit 3)) ]
              [ text "3" ]
          , button [ class "r3 c3 rowspan2 bg-blue", onClick (Clicked Equal) ]
              [ text "=" ]
          , button [ class "r4 c0 colspan2", onClick (Clicked (Digit 0)) ]
              [ text "0" ]
          , button [ class "r4 c2", disabled True ]
              [ text "." ]
          ]
      ]
Enter fullscreen mode Exit fullscreen mode

Key takeaway: Let the UI drive a data structure and unit test the living daylights out of that data structure.

What do you think about this approach to implementing more complicated UIs?

Top comments (0)