Learning Elm Part 4 - Form Validation
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
- Adding Tests
- Form Validation
- Making assumptions explicit
- Pursuing the “stringly” typed alternative
- 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:
SetPageshould set the current page in the modelSetPage Editshould reset the formEditDateshould set the date in the modelEditDescriptionshould set the description in modelEditDestinationshould set the destination in modelEditSourceshould set the source in modelEditAmountshould 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:
formResult : Maybe FormResultto store the validation results.formTransaction : Maybe Transactionto 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:
- Use Result.map3 to create the destination
Entry - Use the same to create the source
Entry - 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)
- First I create my record of Results.
- Next, I use the
Entryconstructor +Result.map3to obtain myResult String Entry - Repeat for the second
Entry - Use the
Transactionconstructor +Result.map4to create theResult String Entry - 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:

And this when the validation succeeds:

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 StringMgs 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.foldlto obtain my newDict String Stringof 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!