Results in F#

I recently wrote a blog post on the Option type in F# and how to use it to represent the abscence of a value, for example when looking up a user in a database by trying to match an integer with a user id. If no user with a matching id exists, Option lets you return None. Compared to returning null, which is quite common in C#, None is a valid value and you wont risk getting a NullReferenceException when returning None.

However, Option does not let you specify any reason, like an error message, that explains what went wrong when None was returned. To remedy that, you can instead use the Result type. A Result in F# can be either Ok or Error where both can be of different types. Let’s look at the example from before, but re-written to use Result.

open System

type CustomerId = CustomerId of uint32
type Customer = { Id: CustomerId }
type Email = Email of string
type ErrorMessage = ErrorMessage of string

type StringToCustomerIdParser = string -> Result<CustomerId, ErrorMessage>
type CustomerLookup = CustomerId -> Result<Customer, ErrorMessage>
type EmailLookup = Customer -> Result<Email, ErrorMessage>
type EmailSender = Email -> unit

let toError s = ErrorMessage s |> Error
let toString (ErrorMessage msg) = msg

let userIdStringParser : StringToCustomerIdParser = fun s ->
    match UInt32.TryParse s with
    | true, num -> Ok (CustomerId num)
    | _ -> toError (sprintf "Could not parse %s to an integer" s)

let getCustomerById : CustomerLookup = fun id ->
    let getValue (CustomerId i) = i

    match id with
    | CustomerId 1u -> Ok { Id = CustomerId 1u }
    | CustomerId 2u -> Ok { Id = CustomerId 2u }
    | _ -> toError (sprintf "No customer with id %d exists" (getValue id))

let getEmailForCustomer : EmailLookup = fun customer ->
    match customer.Id with
    | CustomerId 1u -> Ok (Email "")
    | _  -> toError "Customer has no e-mail"

let sendPromotionalEmail : EmailSender = fun email ->

let main argv =
    if argv.Length <> 1 then
        failwith "Usage: program <user_id>\n"

    let result = 
        userIdStringParser argv.[0]
        |> Result.bind getCustomerById 
        |> Result.bind getEmailForCustomer
        |> sendPromotionalEmail

    match result with
    | Ok _ -> printf "Email sent successfully\n"
    | Error err -> printf "%s"  (toString err)

Take a look at the code in the main function. There are several methods that return a Result and I have used Result.bind and map to create a workflow where the returnvalue from each function call is used as an input parameter to the next function, as long as the result is Ok. If any of the functions returns an Error the rest of the workflow is skipped (Result.bind will not call the next function, instead it will just return the Error from the previous function).

A constraint that is good to be aware of is that the error value type must be the same for all functions. You can see this if you look at the function type declarations that the error value type is always ErrorMessage, which is a type that just wraps a string. If different types were used it would not be possible for Result.bind and map to just return the error from the previous function, since the types would not match. There are ways around this but I won’t go into those now.

Looking at the code again. What do you think will be printed to the console if the number one is given as input, what about 2 and 3? Below is the outcome from some different runs.

$ sendEmailToCustomer 1
Email sent successfully
$ sendEmailToCustomer 2
Customer has no e-mail
$ sendEmailToCustomer 3
No customer with id 3 exists
$ sendEmailToCustomer test
Could not parse 'test' to an integer

How do you like this way of handling errors? Quite different from the usual throwing exceptions and returning null isn’t it. Personally I think that, after a bit of getting used to, this approach makes the code much easier to read and reason about. Notice that there is not a single try-catch, no null guards, and only a single if-statement in the code above.

Lämna en kommentar

Din e-postadress kommer inte publiceras. Obligatoriska fält är märkta *