DEV Community

Cover image for Dealing with workflows in F#
Angel Daniel Munoz Gonzalez
Angel Daniel Munoz Gonzalez

Posted on • Originally published at blog.tunaxor.me

Dealing with workflows in F#

This post is part of the F# Advent Calendar, thanks to @sergey_tihon for organizing this wonderful event each year, don't miss any post of this year! check out https://sergeytihon.com/2021/10/18/f-advent-calendar-2021

Although I was not planning on writing another blog post this year (besides my F# Advent calendar for Dec 16th), I saw there were still a couple of spots for the the F# Advent Calendar and the simple things F# series was at an odd number... we can not let it stay like that right?

This time we will speak about dealing with workflows in F#, F# is a pretty concise language and one of it's strengths in my opinion, is that you can write programs that are often more correct.

Note that when I say correct I don't mean bug free, I simply mean that the compiler is so helpful that it verifies that most of the code simply behaves as you write it. You are still able to encode logic bugs or in some cases stumble upon a real bug.

When you deal with workflows in F# there are often two main ways that I've come to find:

Option

If you are relatively new to F# you may have had some headaches trying to represent null given how common null is in other languages like C#, JavaScript, Java, or C. I do not have to try to convince you about how common is that an unexpected null is a reliable source of bugs. In F# null is not allowed by default (unless you have to inter-operate with C#/VB) so... how do you represent that? Well, the answer is simple: With Options!

let withNumber = Some 10

let withoutNumber = None
Enter fullscreen mode Exit fullscreen mode

Options by themselves are pretty cool but in F# thanks to pattern matching they become really powerful

// The compiler will ensure you cover every match possible
match withNumber, withoutNumber with
| (Some num1, None) ->
  printfn $"Numbers: {num1} - "
| (None, Some num2 -> ) ->
  printfn $"Numbers: - {num2}"
| (Some num1, Some num2) ->
  printfn $"Numbers: {num1} - {num2}"
| None, None ->
  printfn $"Numbers: - "
Enter fullscreen mode Exit fullscreen mode

Hmm, but that might not make a lot of sense, so let's try to read a file

open System.IO

let content = File.ReadAllText("./file.txt")
Enter fullscreen mode Exit fullscreen mode

If you run that you will see a whooping System.IO.FileNotFoundException: Could not find file './file.txt'. or something similar which is not great to be honest, and File.Exists is unreliable, how about making it work in a type safe manner? Sure!

open System
open System.IO

let getContentFromFile filePath =
  try
    let content = File.ReadAllText(filePath)
    if String.IsNullOrWhiteSpace content then
        None
    else
        Some content
  with _ -> None
let content = getContentFromFile "./file.txt"
printfn $"%A{content}"
Enter fullscreen mode Exit fullscreen mode

This should print "None" to the console, if you pass a file path of an existing file it should print the contents.

Options are a good approach when you need to discard or do not care what happened with the data, the main point is just to know the answer to: "Is there any data?"

Result

Well... Options are great, but what if I want to know about what actually happened with my data?

This is where results come in, they help you model workflows where errors are expected and even in some cases, "recover" from those errors.

open System

let randomValue = Random.Shared.Next(0,101)

let result =
  if randomValue % 2 = 0 then
    // return the successful result
    Ok randomValue
  else
    // return the error result
    Error $"Number is Odd: {randomValue}"

printfn $"%A{result}"

Enter fullscreen mode Exit fullscreen mode

If you run this in the FSI (FSharp Interactive) you might get something like this

Ok 80
Enter fullscreen mode Exit fullscreen mode
Error Number is Odd: 89
Enter fullscreen mode Exit fullscreen mode

Hmm, how about also checking if the number is more than 10 but lower than 61?

open System

let randomValue = Random.Shared.Next(0,101)

let result =
  // validate if is odd first
  if randomValue % 2 = 0 then
    // validate if is in bounds
    if randomValue > 10 && randomValue < 61 then
      Ok randomValue
    else
      Error $"Number is Even but out of bounds: {randomValue}"
  else
    Error $"Number is Odd: {randomValue}"

printfn $"%A{result}"
Enter fullscreen mode Exit fullscreen mode

When I ran these two times I got these results:

Error "Number is Even but out of bounds: 90"
Enter fullscreen mode Exit fullscreen mode
Ok 50
Enter fullscreen mode Exit fullscreen mode

That's great but those nested ifs... are not so funny are they? we could separate those validations in different functions

open System

let isOdd value =
  if value % 2 = 0 then
    Ok value
  else
    Error $"Number is Odd: {value}"

let isInBounds (value: int) =
  if value > 10 && value < 61 then
    Ok value
  else
    Error $"Number is out of bounds: {value}"

let randomValue = Random.Shared.Next(0,101)

let result = isOdd randomValue
let result2 = isInBounds randomValue
printfn $"%A{result} - %A{result2}"
Enter fullscreen mode Exit fullscreen mode

Cool we now have two possible results like Error "Number is Odd: 27" - Ok 27 or Ok 68 - Error "Number is out of bounds: 68" or even Ok 20 - Ok 20. If we change our function signature a bit, we will be able to pipe one result into another.

open System

let isOdd value =
  if value % 2 = 0 then
    Ok value
  else
    Error $"Number is Odd: {value}"

// rather than taking an int as the value, take the result itself
let isInBounds (value: Result<int, string>) =
  // use pattern matching to access the result value
  match value with
  | Ok value ->
    if value > 10 && value < 61 then
      Ok value
    else
      // return a new error
      Error $"Number is Even but out of bounds: {value}"
  // here you can choose to return the previous error
  // or to decide if you want to recover from it
  // Error message -> ... code ...
  | previousError -> previousError

let randomValue = Random.Shared.Next(0,101)

let result = isOdd randomValue |> isInBounds
printfn $"%A{result}"
Enter fullscreen mode Exit fullscreen mode

So far we've been using strings for errors but can we make it better?

For sure, we can define a discriminated union that describes these situations

open System

let isOdd value =
  if value % 2 = 0 then
    Ok value
  else
    Error (OddNumber value)

// rather than taking an int as the value, take the result itself
let isInBounds (value: Result<int, ValidationError>) =
    // use pattern matching to access the result value
    match value with
    | Ok value ->
      if value > 10 && value < 61 then
        Ok value
      else
        // return a new error
        Error (OutOfBounds value)
    // here you can choose to return the previous error
    // or to decide if you want to recover from it
    // Error message -> ... code ...
    | previousError -> previousError


let getValue() =
  let randomValue = Random.Shared.Next(0,101)
  // evaluate our random value
  let result = isOdd randomValue |> isInBounds
  match result with
  | Ok value -> $"Value is: {value}"
  | Error (OddNumber value) -> $"Number is Odd: {value}"
  | Error (OutOfBounds value) -> $"Number is Even but out of bounds: {value}"

printfn $"{getValue()}"
Enter fullscreen mode Exit fullscreen mode

After a couple runs you will get the three possible results. Hopefully this sheds some light on how and why

Results vs Exceptions

Let's talk a bit about something that I've seen happen before which is replacing exceptions with results (I have been guilty as well in some cases). In my head, exceptions are for abnormal events on your program, something that is not part of the domain you're working on.

As an example on a student management system:

  • Not being able to reach a third party server.
  • Grade a student which is not enrolled on the course or it is enrolled on a different one.

The first one is related to the environment your program runs on, if the network goes down it's not an error of your application it's precisely an exceptional event that might need to be handled outside your application.

The second one is related to what your system should do which is: manage students.

This doesn't mean that you can't use Results with exceptions, in some cases you actually know something might throw an exception and you want to handle that. Let's think about our safe file reader function, currently it just tells us if there was something there or not it doesn't tell us if there was actually an exception, neither we know if the file was empty. With a few changes we can have that.

open System
open System.IO

type ReaderError =
  | EmptyFile
  | FileNotFound of providedPath: string

let getContentFromFile filePath =
  try
    let content = File.ReadAllText(filePath)
    if String.IsNullOrWhiteSpace content then
      Error EmptyFile
    else
      Ok content
  with
  // as part of our requirements we know the file may exist or not
  | :? FileNotFoundException as ex -> Error (FileNotFound filePath)
  // since other exceptions are not part of our requirements
  // we leave them untouched
  | ex -> reraise()
let content = getContentFromFile "/file.txt"

match content with
| Ok content -> printfn "%s" content
| Error EmptyFile -> printfn "The file was empty"
| Error (FileNotFound path) -> printfn $"The file was not found at {path}"
Enter fullscreen mode Exit fullscreen mode

FsToolkit.ErrorHandling

For the most part we now have an idea what are the uses of Options and Results, once thing that may happen often (hopefully you noticed a hint about it) is when you have nested operations involving Option and Results, you start going in what I call (because I must have seen it somewhere else) the stair of despair


match result with
| Ok username ->
  match queryDB username with
  | Ok user ->
    match doOperation parameter user with
    | Ok () ->  printfn "Success"
    | Error OperationFailed -> $"Error %A{err}"
    | Error PreconditionFailed -> eprintfn $"Error %A{err}"
  | Error NotFound -> eprintfn $"Error %A{err}"
  | Error UnreachableDB ->
    match queryDB username with
    | Ok user ->
      match doOperation parameter user with
      | Ok () ->  printfn "Success"
      | Error OperationFailed -> $"Error %A{err}"
      | Error PreconditionFailed -> eprintfn $"Error %A{err}"
    | Error NotFound -> eprintfn $"Error %A{err}"
    | Error UnreachableDB -> eprintfn $"Error %A{err}"
| Error UsernameNotValid -> eprintfn $"Error %A{err}"
Enter fullscreen mode Exit fullscreen mode

As we saw before could fix them a little bit by creating functions

// declare the errors we might expect
type OperationError =
  | OperationFailed
  | PreconditionFailed
  | NotFound
  | UnreachableDB
  | UsernameNotValid

// get a Result<string, OperationError>
// from somewhere
let getUsername (): Result<string, OperationError> = // ... operation ...

let queryDB (username: Result<string, OperationError>): Result<User, OperationError> =
  match username with
  // simulate a database query
  | Ok username -> database.query username
  | error -> error

let doOperation parameter (user: Result<User, QueryError>): Result<_string_, OperationError> =
  match user with
  // simulate a database query
  | Ok user -> users.updateParameter parameter user
  | error -> error

let operationResult =
  // get the username result
  let result =  getUsername()
  let operationResult =
    result
    |> queryDB
    |> doOperation "Some parameter"
  match operationResult with
  // check if we can re-try in case the DB
  // was not available
  | UnreachableDB ->
    result
    |> queryDB
    |> doOperation "Some parameter"
  // otherwise return the result either success or error
  | result -> result

printfn $"%A{operationResult}"
Enter fullscreen mode Exit fullscreen mode

While that looks quite better, it still falls short some times, in this case the code is very simplistic, but we tend to face code/issues with more complexity. That's where FsToolkit.ErrorHandling shines, FsToolkit.ErrorHandling provides a set of Computation Expressions that in turn give us as well a great DSL to work with options and Results, the example above would be reworked to something like this


// declare the errors we might expect
type OperationError =
  | OperationFailed
  | PreconditionFailed
  | NotFound
  | UnreachableDB
  | UsernameNotValid

// get a Result<string, OperationError> from somewhere
let getUsername () : Result<string, OperationError> = // ... operation ...

// no need to take results as inputs the outputs remain the same
let queryDB (username: string) : Result<User, OperationError> =
  // simulate a database query
  database.query username

// no need to take results as inputs the outputs remain the same
let doOperation parameter (user: User) : Result<_, OperationError> =
  // simulate a database query
  users.updateParameter parameter user

let operationResult =
  result {
    // bind, or ensure that username is indeed the success case
    // which is a string
    let! username = getUsername ()

    // bind, or ensure that the user is indeed a User
    let! user =
      match queryDB username with
      // retry or return the existing result
      | Error UnreachableDB -> queryDB username
      | opResult -> opResult
    // ensure we return the result of the operation
    // we use `return!` because doOperation returns a Result
    // so we need to return and bind it, in another words
    // ensure the value is a success case and return the result
    return! doOperation "someUsername" user
  }
printfn $"%A{operationResult}"
Enter fullscreen mode Exit fullscreen mode

That looks simpler right? Hopefully it looks, what FsToolkit.ErrorHandling is doing here is letting us focus on the "happy path" of our operations without worrying about nesting and do the stair of despair with all of the pattern matching we did on the first example, also look at the "re-try" operation, we used pattern matching and but rather than nest our way to the end we just operated on the success case via let! user =.

If at any point let! username, let! user or return! doOperation fail or have an error, the result computation expression (CE) will shortcut and just return the error case.

If we actually want to recover from errors we need to handle them with the helper functions inside the Result module. Let's go back to the odd/in-range validations example

#r "nuget: FsToolkit.ErrorHandling"

open System
open FsToolkit.ErrorHandling

type ValidationError =
  // let's add two new errors
  | ParseIntFailure of string
  | ParseFloatFailure of string
  // we'll keep the past ones
  | OddNumber of int
  | OutOfBounds of int

let randomValue () =
  Random.Shared.NextDouble() * 100. |> string

let isOdd value =
  if value % 2 = 0 then
    Ok value
  else
    Error(OddNumber value)

let isInBounds value =
  if value > 10 && value < 61 then
    Ok value
  else
    Error(OutOfBounds value)

let tryParseInt (value: string) =
  try
    Ok(value |> int)
  with
  | _ -> Error(ParseIntFailure value)

let tryParseFloat (value: string) =
  try
    Ok(value |> float |> int)
  with
  | _ -> Error(ParseFloatFailure value)

let recoverIntParsingFailure error =
  match error with
  // as long as our "recovery" matches the same
  // return type, we can safely use it
  | ParseIntFailure value -> tryParseFloat value
  | error -> Error error

let finalResult =
  result {
    let! value =
      tryParseInt (randomValue ())
      // BONUS: you can access an error in the middle of a validation chain
      // this can be useful for logging, telemetry, or even fire up events
      |> Result.teeError (fun error -> printfn "Failed to parse int %A" error)
      // Try to recover from parsing an int failure
      |> Result.orElseWith recoverIntParsingFailure
      // teeError (and tee) don not modify the value at all
      |> Result.teeError (fun error -> printfn "Failed to parse float %A" error)

    let! value = isOdd value
    return isInBounds value
  }
printfn $"%A{finalResult}"
Enter fullscreen mode Exit fullscreen mode

In our sample above, we used as a bonus the teeError function which lets us access the error value (if any) to log our error to the console and we re-tried the operation with a float parsing rather than an int parsing, from the beginning we knew int was going to fail but I wanted to show you that, you should be able to model some processes nicely via result workflows. That includes modeling unhappy paths that can be recoverable as well.

Designing them is not one of my strong areas though, so I will defer that whole topic to someone else with more experience.

As a more compelling real-life example from FsToolkit.ErrorHandling check their example

// Given the following functions:
//   tryGetUser: string -> Async<User option>
//   isPwdValid: string -> User -> bool
//   authorize: User -> Async<Result<unit, AuthError>>
//   createAuthToken: User -> Result<AuthToken, TokenError>

type LoginError = InvalidUser | InvalidPwd | Unauthorized of AuthError | TokenErr of TokenError

let login (username: string) (password: string) : Async<Result<AuthToken, LoginError>> =
  asyncResult {
    // requireSome unwraps a Some value or gives the specified error if None
    let! user = username |> tryGetUser |> AsyncResult.requireSome InvalidUser

    // requireTrue gives the specified error if false
    do! user |> isPwdValid password |> Result.requireTrue InvalidPwd

    // Error value is wrapped/transformed (Unauthorized has signature AuthError -> LoginError)
    do! user |> authorize |> AsyncResult.mapError Unauthorized

    // Same as above, but synchronous, so we use the built-in mapError
    return! user |> createAuthToken |> Result.mapError TokenErr
  }
Enter fullscreen mode Exit fullscreen mode

Server applications tend to follow specific workflows and in this example we can see a login flow, this function can be used on the HTTP handler and simplify the code quite a lot, specially when you have to deal with async/task based functions that are also returning or using results.

Final Thoughts

Options and results are actually really useful on the language they can help you ensure your data is consistent and correct. F#'s type inference will also ensure that your data is correct and that you won't have unexpected values where they are not supposed to go.

Hopefully this post sheds some light on options and results as individual concepts and spark ideas on how you can apply them to your F# code.

Also shout out to the wonderful FsToolkit.ErrorHandling library, it simplifies working with these so much.

We'll catch ourselves on the next one!

Discussion (0)