Controlled hallucination

Learning Elm Part 4 - Form Validation

Published: - Reading time: 14 minutes

In part three of our series, we added a small form to create transactions. In this part, we will go a bit further, but not much, by adding some minimum validations.

You can find a deployed version here, with the Elm Debugger here and the source code here.

Table of Contents

  1. Adding Tests
  2. Form Validation
  3. Making assumptions explicit
  4. Pursuing the “stringly” typed alternative
  5. Final thoughts

Adding Tests

We’ll start by adding some tests to the code we wrote in the previous post. We’ll add these tests for our update function:

  1. SetPage should set the current page in the model
  2. SetPage Edit should reset the form
  3. EditDate should set the date in the model
  4. EditDescription should set the description in model
  5. EditDestination should set the destination in model
  6. EditSource should set the source in model
  7. EditAmount should set the amount in model

Except for the first two, they are all very similar. This is the one for EditSource:

editSourceSetsSourceInFormInput : Test
editSourceSetsSourceInFormInput =
    test "EditSource message sets source in model" <|
        \_ ->
            initialModel
                |> update (EditSource "Assets:Gold")
                |> Tuple.first
                |> .formInput
                |> .source
                |> Expect.equal "Assets:Gold"

Nothing special here. The second one is a bit longer, but again, pretty straight forward:

editViewResetsForm : Test
editViewResetsForm =
    let
        formInput : FormInput
        formInput =
            { date = "2024-03-03"
            , description = "Pizza"
            , destination = "Expenses:Eat Out & Take Away"
            , source = "Liabilities:CreditCard"
            , amount = "19.90"
            , currency = "EUR"
            }

        model : Model
        model =
            { initialModel | formInput = formInput }

        expected : FormInput
        expected =
            defaultFormInput initialModel
    in
    test "SetPage Edit rests form" <|
        \_ ->
            model
                |> update (SetPage Edit)
                |> Tuple.first
                |> .formInput
                |> Expect.equal expected

The larger test is the one that verifies that the form renders the input in the model. My first approach was to test the form rendering as one unit, not each field as a separate unit. This means we need several expectations:

editPageRendersFormInput : Test
editPageRendersFormInput =
    let
        formInput : FormInput
        formInput =
            { date = "2024-03-03"
            , description = "Pizza"
            , destination = "Expenses:Eat Out & Take Away"
            , source = "Liabilities:CreditCard"
            , amount = "19.90"
            , currency = "EUR"
            }

        model : Model
        model =
            { initialModel | formInput = formInput, currentPage = Edit }

        expectations : List (Query.Single Msg -> Expect.Expectation)
        expectations =
            [ \q ->
                q
                    |> Query.findAll [ tag "input", attribute (type_ "date"), attribute (value "2024-03-03") ]
                    |> Query.count (Expect.equal 1)
            , \q ->
                q
                    |> Query.findAll [ tag "input", attribute (name "description"), attribute (value "Pizza") ]
                    |> Query.count (Expect.equal 1)
            , \q ->
                q
                    |> Query.find [ tag "select", attribute (name "destination") ]
                    |> Query.children [ tag "option", attribute (value "Expenses:Eat Out & Take Away"), attribute (selected True) ]
                    |> Query.count (Expect.equal 1)
            , \q ->
                q
                    |> Query.find [ tag "select", attribute (name "source") ]
                    |> Query.children [ tag "option", attribute (value "Liabilities:CreditCard"), attribute (selected True) ]
                    |> Query.count (Expect.equal 1)
            , \q ->
                q
                    |> Query.findAll [ tag "input", attribute (name "amount"), attribute (value "19.90") ]
                    |> Query.count (Expect.equal 1)
            ]
    in
    test "Edit Form renders from input" <|
        \_ ->
            model
                |> view
                |> Query.fromHtml
                |> Expect.all expectations

Thanks to the test above, I found a couple of bugs in the code. I was missing name=xxx in some HTML nodes, and I realized I was setting the value attribute for the <select> tags, that is nonsensical. So yey for tests!

Form Validation

Once again I looked through the Semantic UI form documentation, and decided to keep it simple. I would use the form error state and group error messages in one error block at the bottom. I would also use the field error state for each field that had a validation error.

I knew that at some point, I would need to get my FormInput and transform it into a Maybe Transaction - or better yet, a Result ?? Transaction. So I started with the type signature:

validateForm : FormInput -> Result FormResult Transaction

In other words, the validateForm would either succeed with a Transaction, or return a FormResult. I chose to use a record to store the Result of each field validation. This keeps the symmetry between the FormInput and the result of the validation action. Using a record is more strongly typed because it prevents typos (e.g. model.formResult.dte would not compile). Using a Dict String String to hold the errors would be “stringly” typed - a new jargon I learnt from the talk Evan Czaplicki - The life of a file. If I would like to only update the Result of one field (i.e. you fix the error for the Amount field) without recalculating the rest, then I could also see some benefits of using a record.

This is my FormResult:

type alias FormResult =
    { date : Result String Date
    , description : Result String String
    , destination : Result String String
    , source : Result String String
    , amount : Result String Int
    , currency : Result String String
    }

I then added to new attributes to my model:

  1. formResult : Maybe FormResult to store the validation results.
  2. formTransaction : Maybe Transaction to store the validated transaction

The validation function needs to combine 6 Result instances. A Result.map5 does exist, so I could have just ignored our currency - currently hardcoded in this edit view - and used that. But I chose to split it into smaller chunks:

  1. Use Result.map3 to create the destination Entry
  2. Use the same to create the source Entry
  3. Use Result.map4 to combine those two, with the date and description results

I created two small helper functions to improve legibility and remove some duplication:

isFieldNotBlank : String -> String -> Result String String
isFieldNotBlank name value =
    let
        trimmed =
            String.trim value
    in
    if String.isEmpty trimmed then
        Err (name ++ " cannot be blank")

    else
        Ok trimmed


isAmountValid : String -> Result String Int
isAmountValid a =
    case String.toFloat a of
        Nothing ->
            Err ("Invalid amount: " ++ a)

        Just float ->
            if float == 0.0 then
                Err "Amount cannot be zero"

            else
                Ok (float * 100 |> round)

I hope they are simple enough. I have the habit of trimming user input. Not sure if it is good or bad, but sometimes autocomplete in mobile keyboards adds an extra space for you to keep typing and… well, OCD I guess. Not even sure if those extra spaces make it to the server.

I won’t allow zero amounts. I can’t thin of a good reason to do so. It is more likely that the user (me) mistyped.

Now to the long function!

validateForm : FormInput -> Result FormResult Transaction
validateForm input =
    let
        results : FormResult
        results =
            { date = Date.fromIsoString input.date
            , description = isFieldNotBlank "Description" input.description
            , destination = isFieldNotBlank "Destination" input.destination
            , source = isFieldNotBlank "Source" input.source
            , amount = isAmountValid input.amount
            , currency = isFieldNotBlank "Currency" input.source
            }

        destination : Result String Entry
        destination =
            Result.map3
                Entry
                results.destination
                results.currency
                results.amount

        source : Result String Entry
        source =
            Result.map3
                Entry
                results.source
                results.currency
                (results.amount |> Result.map (\amnt -> amnt * -1))

        transaction : Result String Transaction
        transaction =
            Result.map4
                Transaction
                results.date
                results.description
                destination
                source
    in
    transaction |> Result.mapError (\_ -> results)
  1. First I create my record of Results.
  2. Next, I use the Entry constructor + Result.map3 to obtain my Result String Entry
  3. Repeat for the second Entry
  4. Use the Transaction constructor + Result.map4 to create the Result String Entry
  5. Finally, map the error, ignoring the first error and returning the whole FormResult

The idea is that each time the form is validated, we either have a Just FormResult to indicate errors, or a Just Transaction to indicate success. We should reset both to Nothing when we reset the form. And we should wire-up that submit action.

type Msg
    = GotTransactions (Result Json.Decode.Error (List Transaction))
    | ReceiveDate Date
-- (...)
-- OUR NEW MESSAGE
    | SubmitForm


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
-- (...)
        -- RESET FORM
        SetPage Edit ->
            ( { model
                | currentPage = Edit
                , formInput = defaultFormInput model
                , formResult = Nothing
                , formTransaction = Nothing
              }
            , Cmd.none
            )
-- (...)
        -- HANDLE THE NEW MESSAGE
        SubmitForm ->
            let
                validationResult : Result FormResult Transaction
                validationResult =
                    validateForm model.formInput

                formResult : Maybe FormResult
                formResult =
                    case validationResult of
                        Ok _ ->
                            Nothing

                        Err e ->
                            Just e
            in
            ( { model | formResult = formResult, formTransaction = Result.toMaybe validationResult }, Cmd.none )
-- (...)

viewForm : Model -> Html Msg
viewForm model =
-- (...)
    div []
        [ form
            [ class "ui large form"
            , classList
                [ ( "error", isFormError )
                , ( "success", isFormSuccess )
                ]
            -- HOOK-UP THE NEW MESSAGE
            , onSubmit SubmitForm
            ]
-- (...)

The rendering part is quite verbose, and not so interesting.

This is what it looks like when there is an error:

Submit Error

And this when the validation succeeds:

Submit Success

I chose to add some feedback using the plain text accounting format. I eventually import all my data into hledger. But I will remove that success view in a future post, because it is too wide for mobile screens.

Making assumptions explicit

If the code grew larger and was managed by lots of different people, I imagine you could make this intention clearer with a type:

type FormValidation
    = None
    | Error FormResult
    | Valid Transaction

Now that I am typing this, it seems I would benefit from making that change. I can replace both formResult and formTransaction in my model with formValidation : FormValidation.

-- (...)
type alias Model =
    { transactions : List Transaction
    , listItems : List ListItem
    , formInput : FormInput
    -- NEW ATTRIBUTE
    , formValidation : FormValidation
    , settings : Settings
    , currentDate : Date
    , currentPage : Page
    }

-- (...)

initialModel : Model
initialModel =
    { transactions = []
    , listItems = []
    , formInput = emptyFormInput
    -- DEFAULT IS None
    , formValidation = None
    , settings = defaultSettings
    , currentDate = Date.fromCalendarDate 2024 Jan 1
    , currentPage = List
    }

-- (...)

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ReceiveDate date ->
            ( { model | currentDate = date }, Cmd.none )

                SetPage Edit ->
            ( { model
                | currentPage = Edit
                , formInput = defaultFormInput model
                -- RESET TO None
                , formValidation = None
              }
            , Cmd.none
            )
-- (...)
        SubmitForm ->
            let
                formValidation : FormValidation
                formValidation =
                    case validateForm model.formInput of
                        Ok transaction ->
                            Valid transaction

                        Err error ->
                            Error error
            in
            ( { model | formValidation = formValidation }, Cmd.none )

-- (...)
viewFormValidationResult : Model -> Html Msg
viewFormValidationResult model =
    case model.formValidation of
        Error _ ->
            viewFormErrors model

        Valid t ->
            viewFormSuccess t

        None ->
            div [] []

It also cleaned up some code I had in the rendering of the form and errors. So yey for types!

Pursuing the “stringly” typed alternative

At one point, I tried out a different approach, to test its ergonomics. In this version of the code:

  • Both fields and errors were just dictionaries Dict String String
  • I only had one EditFormInput String String Mgs variant to update field values
  • I used pattern-matching to match against the “all success” case of the List (Result String ValidatedValue)
  • I used List.foldl to obtain my new Dict String String of form errors.

I’ll discuss the ValidatedValue in the Final thoughts section.

type ValidatedValue
    = RS String
    | RD Date
    | RF Float

type alias FormInput =
    Dict String String


type alias FormErrors =
    Dict String String

initialModel : Model
initialModel =
    { transactions = []
    -- (...)
    , formInput = Dict.empty
    , formErrors = Dict.empty
    -- (...)
    }

--(...)
type Msg
    = GotTransactions (Result Json.Decode.Error (List Transaction))
    | ReceivedDate Date
    | SetPage Page
    | EditFormInput String String
    | SubmitForm

-- (...)
validateForm : FormInput -> Result FormErrors Transaction
validateForm input =
    let
-- (...)
        results =
            [ ( "date", dateResult )
            , ( "description", descriptionResult )
            , ( "destination", destinationResult )
            , ( "source", sourceResult )
            , ( "amount", amountResult )
            ]

        parsedInput : Maybe SimpleTransactionInput
        parsedInput =
            case results |> List.map Tuple.second of
                [ Ok (RD date), Ok (RS desc), Ok (RS dest), Ok (RS src), Ok (RF amnt) ] ->
                    Just (SimpleTransactionInput date desc dest src (val "currency") (100 * amnt |> round))

                _ ->
                    Nothing

        errors : FormErrors
        errors =
            List.foldl
                (\res acc ->
                    case Tuple.second res of
                        Err msg ->
                            Dict.insert (Tuple.first res) msg acc

                        _ ->
                            acc
                )
                Dict.empty
                results
    in
        case parsedInput of
        Nothing ->
            Err errors

        Just i ->
            Ok (buildTransaction i)
-- (...)
viewForm : Model -> Html Msg
viewForm model =
-- (...)
div [ class "field", classList (isFieldError "description") ]
    [ label [] [ text "Description" ]
    , input
        [ name "description"
        , placeholder "Supermarket"
        , value (val "description")
        , onInput (EditFormInput "description")
        ]
        []
    ]
(...)

All in all, I think I prefer the record of Result rather than this alternative “stringly” typed version mainly because it is a better representation of my mental model at a higher level. Yes, I can see that both the form in put and the form errors can be implemented as a dictionary of string keys, and string values, but they cannot contain any key.

Regarding the “oh, but the typos!” argument against this alternative… meh. I did end up hardcoding "description", etc in many places, but in the other alternative, I still did quite a lot of copy/paste when building my views, and I am sure I can as easily make a mistake where the code will compile (say, using the formInput.description for the destination value) - that type of errors and spelling mistakes would get caught either by users or in tests, hopefully.

Final thoughts

Combining several Result instances into one was a bit harder form me than I expected. You cannot create a list with the results of the FormInput I used, because a Result String Date is not the same type as a Result String Int. In Elm, tuples can hold different types, but they are limited to only three values, which I keep forgetting:

 elm repl
---- Elm 0.19.1 ----------------------------------------------------------------
Say :help for help and :exit to exit! More at <https://elm-lang.org/0.19.1/repl>
--------------------------------------------------------------------------------
> t = ("a", 1, True)
("a",1,True) : ( String, number, Bool )
> t = ("a", 1, True, 2.2)
-- BAD TUPLE -------------------------------------------------------------- REPL

I only accept tuples with two or three items. This has too many:

2| t = ("a", 1, True, 2.2)
       ^^^^^^^^^^^^^^^^^^^
I recommend switching to records. Each item will be named, and you can use the
`point.x` syntax to access them.

Note: Read <https://elm-lang.org/0.19.1/tuples> for more comprehensive advice on
working with large chunks of data in Elm.

That is why, while trying out a different way to combine Result instances, I created the “container” type, ValidatedValue. Once my results were all Result String ValidatedValue, I could happily pattern-match against a list:

case results of
    [ Ok (RD date), Ok (RS description), Ok (RS destination), Ok (RS source), Ok (RF amount) ] ->
        -- Build transaction using extracted validated values
    _ ->
        -- Oh no, some error, or I screwed up the order in the list or..

So far, my experience with Elm is that it not-so-gently pushes you to consider other alternatives into solving certain problems. Restricting tuples to three items is one example.

Restricting Dict to only comparable types is another one. This one kind of bugs me, because in Java, I can happily create a Enum to represent my fields (DATE, DESCRIPTION, SOURCE, etc.) and have a strongly-typed Map<FieldType, String> to model my errors, fields, messages, etc. In Elm however, Dict you can’t do that:

A dictionary mapping unique keys to values. The keys can be any comparable type. This includes Int, Float, Time, Char, String, and tuples or lists of comparable types.

But let’s try it anyway!

 elm repl
---- Elm 0.19.1 ----------------------------------------------------------------
Say :help for help and :exit to exit! More at <https://elm-lang.org/0.19.1/repl>
--------------------------------------------------------------------------------
> import Dict exposing (Dict)
> type InputName = Date | Description
> Date
Date : InputName
> errors : Dict InputName String
| errors = Dict.empty
|   
Dict.fromList [] : Dict InputName String
> Dict.insert Date "Bad Date"
-- TYPE MISMATCH ---------------------------------------------------------- REPL

The 1st argument to `insert` is not what I expect:

8|   Dict.insert Date "Bad Date"
                 ^^^^
This `Date` value is a:

    InputName

But `insert` needs the 1st argument to be:

    comparable

Hint: I do not know how to compare `InputName` values. I can only compare ints,
floats, chars, strings, lists of comparable values, and tuples of comparable
values.

Check out <https://elm-lang.org/0.19.1/comparing-custom-types> for ideas on how
to proceed.

At least they guide you to their suggested solutions Comparing Custom Types!

I hope that in the next part we start interacting again with JavaScript, and get started with persistence.

Until next time!