Published on

Various solutions for solving the Elm Guide age exercise

Step-by-step walkthrough of various solutions to the Elm Guide age challenge intended to help novice Elm users grasp types, type conversions and refactoring logic.

Before We Begin

Please note that:

  • All provided code can be tested online by copy-pasting into the online Elm Try editor
  • this tutorial is aimed at new users with little to no prior knowledge of the Elm functional programming language
  • We assume users have read all information in the Guide up to and including the Forms page
  • We will follow the natural learning curve of novice users in four steps (from most simple to more complex)

The challenge

The Elm Guide contains various cleverly placed "exercises" challenging the user to dig in and solve seemingly simple problems. The one we are addressing is:

Add an additional field for age and check that it is a number.

We are picking up where the code example in the Guide stops so make sure to at least understand that code or, even better, drop a copy into the online Elm Try editor.

Solution 1.

Mimicking earlier instructions in the Guide this is probably the first solution users will come up with:

  • Adding a new record field age with type Int to the Model
  • Making sure the new model field has a default value (0)
  • Extending update Msg with Age String (not Int because onInput always returns a string)
  • Extending update Case-Of with a new pattern for Age
  • Adding a new input field to the view
  • Adding a new if statement to the view viewValidation (with smart user feedback message)

This will either:

  • Update the record's age field with the given input value if a valid number was provided
  • Update the record's age field with hardcoded Result.withDefault value if String.toInt conversion fails
import Html exposing (..)
import Html.App as Html
import Html.Attributes exposing (..)
import Html.Events exposing (onInput)
import String

main =
  Html.beginnerProgram
    { model = model
    , view = view
    , update = update
    }

-- MODEL

type alias Model =
  { name : String
  , password : String
  , passwordAgain : String
  , age: Int
  }

model : Model
model =
  Model "" "" "" 0

-- UPDATE

type Msg
    = Name String
    | Password String
    | PasswordAgain String
    | Age String

update : Msg -> Model -> Model
update action model =
  case action of
    Name name ->
      { model | name = name }

    Password password ->
      { model | password = password }

    PasswordAgain password ->
      { model | passwordAgain = password }

    -- =====================================================
    -- Age get's passed in here as string by `onInput` so we
    -- need to convert it to Int as this is the type required
    -- by our Model definition. Since `String.toInt` conversion
    -- could fail (and thus break our application) we make
    -- sure to set default value to 0 using Result.
    -- =====================================================
    Age age ->
      { model | age = String.toInt age |> Result.withDefault 0}

-- VIEW


view : Model -> Html Msg
view model =
  div []
    [ input [ type' "text", placeholder "Name", onInput Name ] []

    -- =====================================================
    -- Please note that `onInput` will always produce a
    -- string so even when we change the type "string" below
    -- to "number" (so the browser will check for numbers)
    -- we still need to do the String to Int conversion
    -- described above.
    -- =====================================================
    , input [ type' "string", placeholder "Age", onInput Age ] []

    , input [ type' "password", placeholder "Password", onInput Password ] []
    , input [ type' "password", placeholder "Re-enter Password", onInput PasswordAgain ] []
    , viewValidation model
    ]

viewValidation : Model -> Html msg
viewValidation model =
  let
    (color, message) =
      if model.age < 18 then
        ("red", "You are not old enough to watch nude models yet")
      else if model.password == model.passwordAgain then
        ("green", "OK")
      else
        ("red", "Passwords do not match!")
  in
    div [ style [("color", color)] ] [ text message ]

Copy-paste the above code into the online editor and you will see an age field that should behave as expected. Not bad for a first time Elm user, get some coffee.

Solution 2

By now we realize that solution 1 is far from DRY. If we would change the model's default value we would have to update our code in two places ( the model's default value and Result.withDefault value).

The logical step to refactor would be using the model's default value so we update our page a little, this time:

  • Replacing the existing Case-Of with in update a new case for Age
  • Returning the current record as-is (with it's default value for age) if String.toInt conversion fails
  • Updating the record's age field with the integer provided by the user if the conversion succeeds
import Html exposing (..)
import Html.App as Html
import Html.Attributes exposing (..)
import Html.Events exposing (onInput)
import String

main =
  Html.beginnerProgram
    { model = model
    , view = view
    , update = update
    }

-- MODEL

type alias Model =
  { name : String
  , password : String
  , passwordAgain : String
  , age: Int
  }

model : Model
model =
  Model "" "" "" 0

-- UPDATE

type Msg
    = Name String
    | Password String
    | PasswordAgain String
    | Age String

update : Msg -> Model -> Model
update action model =
  case action of
    Name name ->
      { model | name = name }

    Password password ->
      { model | password = password }

    PasswordAgain password ->
      { model | passwordAgain = password }

    -- =====================================================
    -- We still need to convert Age being passed in as type
    -- `String` to `Int` but this time we are not using 
    -- Result.withDefault to set the default value to `0`.
    -- Instead, if String.toInt conversion fails we simply
    -- return the record as-is (effectively using the model's
    -- default value).
    -- =====================================================
    Age age ->
      case String.toInt age of
        Result.Err err -> -- `err` will contain the error message, we could  use `_` and it would be ignored
          model
        Result.Ok age ->
          { model | age = age }

-- VIEW

view : Model -> Html Msg
view model =
  div []
    [ input [ type' "text", placeholder "Name", onInput Name ] []
    , input [ type' "string", placeholder "Age", onInput Age ] []
    , input [ type' "password", placeholder "Password", onInput Password ] []
    , input [ type' "password", placeholder "Re-enter Password", onInput PasswordAgain ] []
    , viewValidation model
    ]

viewValidation : Model -> Html msg
viewValidation model =
  let
    (color, message) =
      if model.age < 18 then
        ("red", "You're not old enough to watch nude models yet")
      else if model.password == model.passwordAgain then
        ("green", "OK")
      else
        ("red", "Passwords do not match!")
  in
    div [ style [("color", color)] ] [ text message ]

Again, copy-paste this code into the online editor. It should behave as expected.

Solution 3

Even though solution 2 is already a lot nicer we would still need to duplicate a lot of Case-Of logic in the update layer if we were to add another input field requiring an integer.

The logical step to refactor would be creating a custom function for the type conversion so we can re-use the logic. Again we update our page, this time:

  • Adding a custom function inputStringToInt (using String as parameter, returning Int)
  • Making the function return an Int if type conversion succeeds
  • Making the function return 0 if the type conversion fails
import Debug
import Html exposing (..)
import Html.App as Html
import Html.Attributes exposing (..)
import Html.Events exposing (onInput)
import String

main =
  Html.beginnerProgram
    { model = model
    , view = view
    , update = update
    }

-- MODEL

type alias Model =
  { name : String
  , password : String
  , passwordAgain : String
  , age: Int
  }

model : Model
model =
  Model "" "" "" 0

-- UPDATE

type Msg
    = Name String
    | Password String
    | PasswordAgain String
    | Age String


update : Msg -> Model -> Model
update action model =
  case action of
    Name name ->
      { model | name = name }

    Password password ->
      { model | password = password }

    PasswordAgain password ->
      { model | passwordAgain = password }

    -- =====================================================
    -- Convert age to Int using custom function so we can
    -- re-use logic for other input fields requiring Ints.
    -- =====================================================
    Age age ->
      { model | age = inputStringToInt age }

-- VIEW

view : Model -> Html Msg
view model =
  div []
    [ input [ type' "text", placeholder "Name", onInput Name ] []
    , input [ type' "string", placeholder "Age", onInput Age ] []
    , input [ type' "password", placeholder "Password", onInput Password ] []
    , input [ type' "password", placeholder "Re-enter Password", onInput PasswordAgain ] []
    , viewValidation model
    ]

viewValidation : Model -> Html msg
viewValidation model =
  let
    (color, message) =
      if model.age < 18 then
        ("red", "You're not old enough to watch nude models yet")
      else if model.password == model.passwordAgain then
        ("green", "OK")
      else
        ("red", "Passwords do not match!")
  in
    div [ style [("color", color)] ] [ text message ]

-- CUSTOM FUNCTIONS

inputStringToInt : String -> Int

inputStringToInt input =
  let result = String.toInt input
  in case result of 
    Err msg -> 0 -- `msg` will contain the error message, we could use `_` instead and it would be ignored
    Ok converted ->  converted

By know you should know the drill so... copy, paste, enjoy and get some coffee (the good part is coming next).

Solution 4.

By now the concept of types should have landed and if things went well there are probably two things on your mind right now:

  1. The major flaw in all previous solutions: how to make age optional (since we don't want 0 as default value)
  2. How on earth that would ever be possible knowing that Elm doesn't support null or undefined?

In all honesty, this is also probably the moment you should join the Elm Slack beginners channel and hope some of the friendly people out there will help you out.

Luckily... we've already done that and the solution for our problem is found in the Elm core library Maybe, literally stating:

A Maybe can help you with optional arguments, error handling, and records with optional fields.

For the last time we will update our page, this time:

  • Changing the model's age field type to Maybe Int
  • Changing the model's age field default value to Nothing (think of it as null)
  • Updating our custom function inputStringToInt to:
    • return Maybe Int (instead of Int)
    • return Nothing if the type conversion fails
    • return a Just Int value if the conversion succeeds

Please note that we also MUST replace the if statement inside viewValidation with a Case-Of because standard Elm comparisons will only accept basic types like integers, strings, etc.

import Html exposing (..)
import Html.App as Html
import Html.Attributes exposing (..)
import Html.Events exposing (onInput)
import String

main =
  Html.beginnerProgram
    { model = model
    , view = view
    , update = update
    }

-- MODEL

type alias Model =
  { name : String
  , password : String
  , passwordAgain : String
  , age: Maybe Int
  }

model : Model
model =
  Model "" "" "" Nothing

-- UPDATE

type Msg
    = Name String
    | Password String
    | PasswordAgain String
    | Age String -- we keep this a String because that's what we get from `onInput` (converted to Maybe Int before inserting into model)


update : Msg -> Model -> Model
update action model =
  case action of
    Name name ->
      { model | name = name }

    Password password ->
      { model | password = password }

    PasswordAgain password ->
      { model | passwordAgain = password }

    Age age ->
       { model | age = inputStringToInt age }

-- VIEW


view : Model -> Html Msg
view model =
  div []
    [ input [ type' "text", placeholder "Name", onInput Name ] []
    , input [ type' "string", placeholder "Age", onInput Age ] []
    , input [ type' "password", placeholder "Password", onInput Password ] []
    , input [ type' "password", placeholder "Re-enter Password", onInput PasswordAgain ] []
    , viewValidation model
    ]

viewValidation : Model -> Html msg

viewValidation model =
  let
    (color, message) =
      case model.age of
        Nothing ->
          ("red", "Please provide your age")
        Just age -> if age < 18 then
          ("red", "You're not old enough to watch nude models yet!")
          else if model.password == "" then
            ("red", "Please provide a password")                
          else if model.password == model.passwordAgain then
           ("green", "OK")
          else
            ("red", "Passwords do not match!")

  in
    div [ style [("color", color)] ] [ text message ]

-- CUSTOM FUNCTIONS

inputStringToInt : String -> Maybe Int

inputStringToInt input =
  case String.toInt input of
    Err _ -> Nothing
    Ok converted -> Just converted

Copy, paste and be merry. We have not only completed the age challenge, in the process we learned about types and how to convert them while keeping our code DRY and highly maintainable. Thank you Elm!

Closing Notes

As with all learning curves these are obviously only the first baby steps into the world called Elm but it should provide you with enough confidence to continue with the next chapter of the Guide.

Another thing to note is that "there is more than one way to do it" and thus the provided solutions are non-exhaustive and most likely a lot of different approaches are possible and they may all be valid or even better.

For example, the following feedback was provided on Slack:

if condition then x else y

is equivalent to

case condition of
  True -> x
  False -> y

Where the first is more readable, and abstracts away the pattern matching. I would always prefer using abstraction instead of direct pattern matching in general, because that means if the implementations of the type changes in the future (unlikely for Bool types) you won’t have to update every single function that consumes the type. It’s effectively a way to achieve encapsulation.

So still lots to explore, learn and try but remember... if it compiles it's good ;)

Big thank you to Elm Community members mattsenior, szabba and iteloo for their help while creating this post.