avatar

Dimos Michailidis

Posted on 31st October 2023

Error Handling in F#

news-paper News | Other Languages | Software Development |

A Practical Approach Through a Console Application

All developers will encounter error handling at some point, especially when working with strongly-typed functional programming languages. This article will focus on the basic error-related types found in F# and how to use them practically.

We will explore error handling by creating a simple console application that takes three inputs:
1. A month in the yyyy-MM format,
2. Working hours as a positive integer,
3. Hourly rate as a positive decimal.

This application will calculate and display a worker’s monthly earnings.

We will start with a basic program that gets the job done. Then, step by step, we will improve it. In each section of the article, a new error-handling concept will be introduced and explained.

All the code you’ll see is in this repo, with different commits for each section.

Basic Implementation

If our machine has the .NET Core SDK installed, we can create a new F# console application by running the following commands, and then navigating into the newly-created directory.

dotnet new console -lang F# -o EarningsCalculator
cd EarningsCalculator

Replace the contents of the Program.fs file with the following code:

open System
open System.Globalization

[<EntryPoint>]
let main args =
    match args with
    | [| monthArg; hoursArg; rateArg |] ->
        DateTime.ParseExact(monthArg, "yyyy-MM", null, DateTimeStyles.None) |> ignore
        let hours = hoursArg |> int |> decimal
        let rate = Decimal.Parse(rateArg)
        Console.WriteLine $"Your earnings for {monthArg} are {hours * rate}"
       0
    | _ ->
        Console.WriteLine "Please provide three arguments"
        1

In this first version, our program checks if all three arguments are provided, parses them, calculates, and prints the result:
1. We parse the monthArg to ensure it’s in the yyyy-MM format, but we ignore the parsed value.
2. To multiply the working hours by the hourly rate, we convert the hoursArg from string to int, and then to decimal.
3. We parse the rateArg as decimal.

Now, let’s check the behavior of our program! If we execute dotnet run 2023-01 150 30.0, we’ll get “Your earnings for 2023-01 are 4500.0“. That’s nice, but what if we provide invalid input? If we execute dotnet run 2022-13 150 30.0, our program will crash due to a FormatException related to DateTime. It’s clear we need to handle errors. Instead of catching exceptions, we will use appropriate types in the upcoming sections.

The Option Type

Strongly-typed functional languages like F# discourage null values to avoid null-related exceptions. Instead, they employ specific types to deal with missing values, and in F#, the Option type serves this purpose.

To handle potential invalid hourly rate values, we could treat the parsed rate as a decimal option. Here, Some rate would indicate a successful parsing result, while None would signify failure.

Let’s replace the contents of the Program.fs file with functions that utilize the Option type to handle parsing failures:

open System
open System.Globalization

let monthOpt monthArg =
    match DateTime.TryParseExact(monthArg, "yyyy-MM", null, DateTimeStyles.None) with
    | true, _ -> Some monthArg
    | _ -> None

let hoursOpt (hoursArg: string) =
    match Int32.TryParse hoursArg with
    | true, hours when hours >= 0 -> Some hours
    | _ -> None

let rateOpt (rateArg: string) =
    match Decimal.TryParse rateArg with
    | true, rate when rate >= 0.0m -> Some rate
    | _ -> None

Instead of the Parse functions we used in the first version of the program, we now employ their TryParse counterparts which do not throw exceptions. They return a tuple of a bool and another value. On success, the first element of the tuple is true and the second one is the parsed value. On failure, the first element of the tuple is false and the second one is a default value.

We introduced the option values, but what will we do now? Well, there are many ways to combine them. We will start by looking at the simplest one.

Let’s add the following lines to the file:

let earningsOpt monthArg hoursArg rateArg =
    match monthOpt monthArg, hoursOpt hoursArg, rateOpt rateArg with
    | Some _, Some hours, Some rate -> Some(decimal hours * rate)
    | _ -> None

[<EntryPoint>]
let main args =
    match args with
    | [| monthArg; hoursArg; rateArg |] ->
        match earningsOpt monthArg hoursArg rateArg with
        | Some earnings ->
            Console.WriteLine $"Your earnings for {monthArg} are {earnings}"
            0
        | None ->
            Console.WriteLine "Your input is invalid"
            1
    | _ ->
        Console.WriteLine "Please provide three arguments"
        1

If all input values are parsed successfully, the result is calculated and printed. Otherwise, a new error message is printed to the user. The current version of the program seems impossible to crash. Yet, we can improve it further.

Short Circuit Evaluation

The initial implementation of the earningsOpt function does not allow for early exits. If the monthOpt monthArg expression evaluates to None, the other expressions within the tuple will still be evaluated. For the earningsOpt function to be as efficient as possible, we should introduce short circuit evaluation. This means if monthOpt monthArg evaluates to None, the evaluation of hoursOpt hoursArg and rateOpt rateArg should be skipped.

Let’s replace the old earningsOpt function with this one:

let earningsOpt monthArg hoursArg rateArg =
    match monthOpt monthArg with
    | None -> None
    | Some _ ->
        match hoursOpt hoursArg with
        | None -> None
        | Some hours ->
            match rateOpt rateArg with
            | None -> None
            | Some rate -> Some(decimal hours * rate)

This new implementation of the earningsOpt function allows for early exits, enhancing efficiency.

Computation Expressions for Options

To enhance the readability of the earningsOpt function, we can replace the nested pattern matching with computation expressions. Computation expressions provide a way to handle effects such as optionality, streamlining the error-handling process. This makes the code more declarative and less error-prone. To use computation expressions for error-handling types, we can install the FsToolkit.ErrorHandling package.

dotnet add package FsToolkit.ErrorHandling

With the new package installed, we can re-implement the earningsOpt function in a cleaner manner.

open FsToolkit.ErrorHandling

let earningsOpt monthArg hoursArg rateArg =
    option {
        let! _ = monthOpt monthArg
        let! hours = hoursOpt hoursArg
        let! rate = rateOpt rateArg
        return decimal hours * rate
    }

In this implementation, the let! part of the computation expression binds the value contained within an Option type to a variable if it’s Some. Otherwise, It short-circuits the computation, returning None. The return keyword wraps a value in a Some, providing a result of the Option type for a computation expression block.

Let’s check the behavior of our program. If we execute dotnet run 2022-13 150 30.0, we’ll get “Your input is invalid“.

In the next section, we’ll see how we can provide a more descriptive error message to the user. A message that will clarify what went wrong.

The Result Type

Strongly-typed functional languages offer distinct types for error handling that encapsulate either a successful result or an error. In F#, the Result type embodies this practice.

To manage potential parsing-related errors, we could encapsulate the outcome in a Result. Here, Ok value would represent a successful computation, while Error "some error message" would provide a descriptive error message in case of failure.

Replace the contents of the Program.fs file with the following functions that utilize the Result type:

open System
open System.Globalization

let monthRes monthArg =
    match DateTime.TryParseExact(monthArg, "yyyy-MM", null, DateTimeStyles.None) with
    | true, _ -> Ok monthArg
    | _ -> Error "The first argument should be a valid month in the yyyy-MM format"

let hoursRes (hoursArg: string) =
    match Int32.TryParse hoursArg with
    | true, hours when hours >= 0 -> Ok hours
    | _ -> Error "The hours argument should be a positive integer"

let rateRes (rateArg: string) =
    match Decimal.TryParse rateArg with
    | true, rate when rate >= 0.0m -> Ok rate
    | _ -> Error "The rate argument should be a positive decimal"

let earningsRes monthArg hoursArg rateArg =
    match monthRes monthArg with
    | Error msg -> Error msg
    | Ok _ ->
        match hoursRes hoursArg with
        | Error msg -> Error msg
        | Ok hours ->
            match rateRes rateArg with
            | Error msg -> Error msg
            | Ok rate -> Ok(decimal hours * rate)

[<EntryPoint>]
let main args =
    match args with
    | [| monthArg; hoursArg; rateArg |] ->
        match earningsRes monthArg hoursArg rateArg with
        | Ok earnings ->
            Console.WriteLine $"Your earnings for {monthArg} are {earnings}"
            0
        | Error msg ->
            Console.WriteLine msg
            1
    | _ ->
        Console.WriteLine "Please provide three arguments"
        1

Let’s recheck the behavior of our program:
1. dotnet run 2022-13 150 30.0 gives “The first argument should be a valid month in the yyyy-MM format“.
2. dotnet run 2023-01 hundred 30.0 gives “The hours argument should be a positive integer“.
3. dotnet run 2023-01 150 thirty gives “The rate argument should be a positive decimal“.

Computation Expressions for Results

With the FsToolkit.ErrorHandling package, we can also write computation expressions for the Result type. This simplifies the handling of Result types by reducing nesting and improving readability. To maintain the same behavior, we can replace the implementation of the earningsRes function:



open FsToolkit.ErrorHandling

let earningsRes monthArg hoursArg rateArg =
    result {
        let! _ = monthRes monthArg
        let! hours = hoursRes hoursArg
        let! rate = rateRes rateArg
        return decimal hours * rate
    }

Computation Expressions for Validations

This section of the article addresses error aggregation. Instead of only reporting the first error encountered, we will accumulate all errors and report them to the user. This is a typical validation process, and fortunately, the FsToolkit.ErrorHandling package provides computation expressions for this purpose.

Replace the earningsRes and main functions with the following code:

let earningsRes monthArg hoursArg rateArg =
    validation {
        let! _ = monthRes monthArg
        and! hours = hoursRes hoursArg
        and! rate = rateRes rateArg
        return decimal hours * rate
    }

[<EntryPoint>]
let main args =
    match args with
    | [| monthArg; hoursArg; rateArg |] ->
        match earningsRes monthArg hoursArg rateArg with
        | Ok earnings ->
            Console.WriteLine $"Your earnings for {monthArg} are {earnings}"
            0
        | Error messages ->
            List.iter (fun (msg: string) -> Console.WriteLine msg) messages
            1
    | _ ->
        Console.WriteLine "Please provide three arguments"
        1

In this updated implementation, the validation and the and! parts of the computation expression allow error aggregation. If any parsing function fails, its error message gets accumulated. In the main function, we use the List.iter function to iterate over the list of error messages and print each one to the console.

Let’s check the behavior of our program one more time. If we execute dotnet run 2022-13 hundred thirty will get this:

The first argument should be a valid month in the yyyy-MM format
The hours argument should be a positive integer
The rate argument should be a positive decimal

The Elephant in the Room

It’s time to say it out loud. Yes, we’ve been talking about monads! So, for our type-theory enthusiasts, you can now relax. We’ve acknowledged the monadic essence of this article.

Conclusion

We explored error handling in F# by building a simple console application. We started with a basic version and step by step, introduced better error-handling methods common in strongly-typed functional programming languages. Through this, we looked into the Option and Result types, short circuit evaluation, and computation expressions. These tools helped make our application more robust, easier to read, and maintain.