Things don't always work out the way we planned
This is true in most walks of life, and it is also true in our code. Whenever we deal with an indeterminate state, such as processing user input or calling an external API, we have to consider the possible different outcomes.
Getting Results
In F#, if a function may possibly succeed or fail, we usually model this using the included Result<'a,'b> type, where 'a
is the type we expect for the Ok
case, and 'b
for the Error
case.
We often find ourselves in a situation where our workflow consists of a chain of functions, each of which requires the output of the previous, and each may fail (and hence returns a Result
).
This is where we would often reach for the Result.bind
function. This executes a given function, and if it returns Ok
, passes its output to the input of the next Result
-returning function in the chain.
However, in the case that any of the operations return an Error
, the remaining functions are not executed. The Error
is returned immediately.
You may have heard of this as Railway Oriented Programming, and it is a monadic way of dealing with
Result
s.
open System
let random = Random()
let callIntegerApi () =
if random.Next() % 7 = 0 then
Error "Something went wrong"
else
Ok (random.Next(), random.Next())
let tryDivideInteger (x, y) =
if x = 0 || y = 0 then
Error "Can't divide by Zero"
else
Ok (x / y)
let stringEvenNumbers x =
if x % 2 = 0 then
Ok $"{x}" // F# 5 string interpolation 😊
else
Error "Can't parse odd numbers"
callIntegerApi ()
|> Result.bind tryDivideIntegers
|> Result.bind stringEvenNumbers
Running this code a few times in F# Interactive shows different results:
val it : Result<string,string> = Ok "4"
...
val it : Result<string,string> = Error "Can't parse odd numbers"
etc.
Sharpening our Tools
An alternative way of using Result.bind
is as a computation expression.
result {
let! randomIntPair = callIntegerApi ()
let! divideResult = tryDivideIntegers randomIntPair
return! stringEvenNumbers divideResult
}
This Result
expression is not available out-of-the-box with FSharp, but it is included, along with many other essential tools, in a fantastic library called FSToolkit.
Needing Validation
In the previous example, aborting the workflow on the first error made sense.
Each function depended on the result of the previous, so if any failed then there was no other option.
This isn't always the case though. Sometimes we have a bunch of functions we want to execute independently, perhaps in parallel, and then we want to use all of the results for a single operation at the end.
We either want to get the desired outcome, or a list of all the errors that occured, not just the first.
This is an applicative way of dealing with
Result
s, as opposed to the monadic approach we saw earlier.
A common example of this kind of workflow is form validation.
It would be very frustrating when filling in a form if you made multiple errors but were only told of the first one each time you pressed submit. You want to submit the form once and find out all of the things which need fixing.
Well, FSToolkit comes to the rescue again. It now provides a validation
computation expression which leverages the new and!
operator added to F#5. This allows it to act applicatively, rather than monadically.
#r "nuget: FSToolkit.ErrorHandling" // F# 5 direct nuget references in scripts 😊
open FsToolkit.ErrorHandling
open System
type Customer =
{ Name : string
Height : int
DateOfBirth : DateTime }
let validateName name =
if String.IsNullOrWhiteSpace name then
Error "Name can't be empty"
else
Ok name
let validateHeight height =
if height > 0 then
Ok height
else
Error "Everything has a height"
let validateDateOfBirth dob =
if dob < DateTime.UtcNow then
Ok dob
else
Error "You can't be born in the future"
let validateCustomerForm name height dob =
validation {
let! validName = validateName name
and! validHeight = validateHeight height
and! validDob = validateDateOfBirth dob
return { Name = validName; Height = validHeight; DateOfBirth = validDob }
}
validateCustomerForm "Joe Bloggs" 180 (DateTime(1980, 4, 1))
val it : Validation<Customer,string> =
Ok { Name = "Joe Bloggs"
Height = 180
DateOfBirth = 01/04/1980 00:00:00 { Date = 01/04/1980 00:00:00;
Day = 1;
DayOfWeek = Tuesday;
DayOfYear = 92;
Hour = 0;
Kind = Unspecified;
Millisecond = 0;
Minute = 0;
Month = 4;
Second = 0;
Ticks = 624589920000000000L;
TimeOfDay = 00:00:00;
Year = 1980; } }
validateCustomerForm "" 0 (DateTime(1980, 4, 1))
val it : Validation<Customer,string> =
Error ["Name can't be empty"; "Everything has a height"]
As you can see, this is a really concise and clear way of modelling validation. It is also a great example of the power of applicatives, which are now even easier to use with the arrival of F# 5.