Grokking Applicative Validation
Matt Thornton
Posted on April 16, 2021
Previously in Grokking Applicatives we discovered Applicatives and more specifically invented the apply
function. We did this by considering the example of validating the fields of a credit card. The apply
function allowed us to easily combine the results we obtained from validating the number, expiry and CVV individually into a Result<CreditCard>
that represented the validation status of an entire CreditCard
. You might also remember we somewhat glossed over the error handling when multiple fields were invalid. We took the easy road and just returned the first error that occurred.
An unhappy customer đĄ
In the spirit of agile we decided to ship our previous implementation, because, well it was better than nothing. A short while later, customers start complaining. All the complaints are along these lines.
"I entered my credit card details on your site and it took three attempts before it was finally accepted. I submitted the form and each time it gave me a new error. Why couldn't you tell me about all the errors at once?"
To see this more clearly consider a customer that enters the following data, in JSON form.
{
ânumberâ: âa bad numberâ,
âexpiryâ: âinvalid expiryâ,
âcvvâ: ânot a CVVâ
}
The first time they submit the form they get an error like ââa bad numberâ is not a valid credit card numberâ
. So they fix that and resubmit. Then they get a message like ââinvalid expiryâ is not a valid expiry dateâ
. So they fix that and submit a third time and still receive an error along the lines of âânot a CVVâ is not a valid CVVâ
. Pretty annoying!
We should be able to do better and return all of the errors at once. We even previously pointed out that all of the field level validation functions were independent of each other. So there was no good reason not to run all of the functions and aggregate the errors if there were any, we were just being lazy!
A better validation Applicative đȘ
Let's start by updating the signature of validateCreditCard
to signify our new desire to return all the validation errors that we find.
let validateCreditCard (card: CreditCard): Result<CreditCard, string list>
The only change here is that weâre now returning a list
of error messages rather than a single one. How should we update our implementation to satisfy this new signature?
Letâs return to the apply
function that we defined before and see if we can just fix it there. It would be very nice if all we had to do was modify apply
and leave validateCreditCard
otherwise unchanged.
For reference hereâs the apply
function that we wrote last time, the one that returns the first error it encounters.
let apply a f =
match f, a with
| Ok g, Ok x -> g x |> Ok
| Error e, Ok _ -> e |> Error
| Ok _, Error e -> e |> Error
| Error e1, Error _ -> e1 |> Error
We can see from this that itâs only the final case where we have multiple errors to deal with and so itâs only there that we need to fix things. The simplest fix is to just concatenate both errors. This has the effect of building up a list of errors each time we call apply
with invalid data. Letâs see what that looks like then.
let apply a f =
match f, a with
| Ok g, Ok x -> g x |> Ok
| Error e, Ok _ -> e |> Error
| Ok _, Error e -> e |> Error
| Error e1, Error e2 -> (e1 @ e2) |> Error
That was easy, we just used @
to concatenate the two lists in the case where both sides were Error
. Everything else remained the same.
Letâs walk through validating the credit card step-by-step with the example of the bad data that the customer was supplying earlier. First we call Ok (createCreditCard) |> apply (validateNumber card.Number)
. This hits the third case of the pattern match in apply
because f
is Ok
, but the argument a
is Error
. That returns us something like an Error [ âInvalid numberâ ]
, but whose type is still Result<string -> string -> CreditCard, string list>
.
We then pipe this like |> apply (validateExpiry card.Expiry)
. This hits the final case in the pattern match because now both f
and a
are Error
. This means the @
operator is used to concat the errors together to create something like Error [ âInvalid expiryâ; âInvalid numberâ ]
. The type of which is now Result<string -> CreditCard, string list>
because we now just need to supply a CVV to finish creating the CreditCard
.
So in the final step we do exactly that and pipe this result like |> apply (validateCvv card.Cvv)
. Just like the last step we hit the case where both f
and a
are Error
and so we concat them. Now weâve got something with the type Result<CreditCard, string list>
as we wanted with a value like Error [ âInvalid CVVâ; âInvalid expiryâ; âInvalid numberâ ]
.
A small compile time error
You might have spotted that weâve actually changed the type of the apply
function now. By using the @
operator F# has inferred that the errors must be a list
. So now the signature of apply
is Result<T, E list> -> Result<T -> V, E list> -> Result<V, E list>
.
We now have an apply
that works for Result<T, E list>
. That is, it works for any results where the errors are contained in a list
, rather than being single values like a string
. There are a couple of interesting points to make about this:
- The errors in the list can be any type, providing theyâre all of the same type.
- All of our validated results must now have a
list
of errors if we want to use them withapply
.
Point 1 is useful because it allows us to model our errors in more meaningful ways than just using strings. Although for the rest of this post weâll keep using string
in order to keep it simple. Modelling errors deserves a blog post of its own.
Point 2 however causes us a little problem we have to solve here. Our original field level validation functions are still returning Result<string, string>
so they no longer work with our new version of apply
.
We have two choices when it comes to fixing this issue. We could keep the functions as they are and transform their outputs by wrapping the error, if it exists, of the result in a list
. Which might look something like this.
let validateCreditCard (card: CreditCard): Result<CreditCard, string list> =
let liftError result =
match result with
| Ok x -> Ok x
| Error e -> Error [ e ]
Ok (createCreditCard)
|> apply (card.Number |> validateNumber |> liftError)
|> apply (card.Expiry |> validateExpiry |> liftError)
|> apply (card.Cvv |> validateCvv |> liftError)
The other choice is to update those field validation functions so that they return Result<string, string list>
as required. It might be tempting to take the first choice and if we had no control over those functions weâd have to do that. However, by letting those field level functions return a list we give them the flexibility to do more complex validation and potentially indicate multiple errors.
For instance the validateNumber
function could indicate both a problem with the length and the presence of invalid characters like this.
let validateNumber number: Result<string, string list> =
let errors =
if String.length num > 16 then
[ "Too long" ]
else
[]
let errors =
if num |> Seq.forall Char.IsDigit then
errors
else
"Invalid characters" :: errors
if errors |> Seq.isEmpty then
Ok num
else
Error errors
Using Result<T, E list>
throughout gives us a more composable and flexible api that allows us to refactor the errors returned from those functions in the future without affecting the rest of the program.
So given that theyâre functions in our domain, then weâll take that approach. Letâs give that a try and see what it looks like all together when using this new version of apply
.
let validateNumber num: Result<string, string list> =
if String.length num > 16 then
Error [ âToo longâ ]
else
Ok num
let validateExpiry expiry: Result<string, string list> =
// validate expiry and return all errors we find
let validateCvv cvv: Result<string, string list> =
// validate cvv and return all cvv errors we find
let validateCreditCard (card: CreditCard): Result<CreditCard, string list> =
Ok (createCreditCard)
|> apply (validateNumber card.Number)
|> apply (validateExpiry card.Expiry)
|> apply (validateCvv card.Cvv)
Lovely job! Apart from a couple of small changes to lift the errors up into lists within validateNumber
etc the rest has stayed the same. In particular, the body of validateCreditCard
is completely unchanged.
Do I have to use list for the errors
The only requirement weâve placed on the error type is that we can use the @
operator to concat the errors together. So as long as the errors are concat-able then we can use a different type here. The fancy category theory name for this is a semi-group. A semi-group is anything that has at least a concat operator defined for it. A common type to use here is a NonEmptyList
, because we know that if the result is an Error
then theyâll be at least one item in the list.
A tale of two applicatives đ
Weâve seen two implementations of apply
for Result
now. Can we have both? Unfortunately not really, at least not both defined in the Result
module of F#. In order to do this F# would have to be able to decide which one to use based on whether the error type supported concat, which might not even be obvious without an explicit type annotation. Even then we might get undesired results because strings support concat, but itâs unlikely we want to concat the individual error messages into one long string.
How should we decide which one is correct then? Well, we donât have to. We can define another type called Validation
which has a Success
case and a Failure
case, similar to Ok
and Error
for Result
. The difference is that for Validation
we can define apply
using the version weâve created in this post which accumulates errors and for Result
use the apply
function that short circuits and returns the first error, that we saw in the last post. Luckily for us the excellent FSharpPlus library has already done exactly that.
What did we learn đ§âđ«
Weâve seen that applicatives are a great tool to have at our disposal when writing validation code. They allow us to write validation functions for each field and then easily compose these to create functions for validating larger structures made up of those fields.
Weâve also seen that whilst applicative computations are usually independent of each other thereâs nothing to guarantee that a particular implementation of apply
will make full use of this. Specifically, when working with validation we want to make sure that apply
accumulates all errors and so we should make sure to use a type like Validation
from FSharpPlus to get this behaviour.
Posted on April 16, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.