2

Aggregating Errors in F#

 1 year ago
source link: https://medium.com/creating-totallymoney/aggregating-errors-in-f-bcebbfa8455c
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
1*_IjjS-VBFAJs8DB6mEs3kA@2x.jpeg

Aggregating Errors in F#

How to handle collections of errors with applicative computation expressions

With F# we can use Result types and computation expressions to allow us to handle errors in a way that doesn’t clutter our functions while giving us readable, imperative-looking code.
In this article, I’ll show you how to go a step further and enhance these computation expressions with functionality similar to the AggregateException class, without having to use any Exceptions.

Firstly, If you aren’t familiar with Result types or computation expressions, I’ll give a quick overview of them now, feel free to skip the first two sections if you’re already an expert.

Result Types

‘Result’ in F# is a type that can be one of two possible types, one for success (the ‘Ok’ case) and one for errors (the ‘Error’ case). This means that we have the option to return a different type when we have an error rather than throwing exceptions (which generally isn’t an idiomatic method of error handling in functional languages, especially for expected error cases).
Rather than throwing exceptions and leading to potentially confusing workflows, where readers of the code must jump about in a codebase looking for where exceptions are caught or the appropriate exception handler, the ‘Result’ type allows us to chain our functions together and pass the error down to the end of the function.

For example:

getSomeData yourInput
|> Result.map filterOutUnwantedData
|> Result.map convertResultsToDtos
|> Result.bind storeResultsInDatabase

Here we have a getSomeData function which returns a Result type.
The Result.map and Result.bind functions both handle their input being an Error by returning the same error rather than performing their operation, there’s no need for the code to be cluttered by lines which check if the result is an error or not.

Computation Expressions

Computation expressions are a very interesting feature of F# which effectively allow you to design changes to the language!
There are a lot of restrictions of course, but in simple terms, in a computation expression certain reserved words have different behaviours and we have some freedom over what they do.
One of the most useful computation expressions is the ‘result’ expression, in which the ‘let!’ keyword not only binds a value to an identifier but also implicitly handles errors!
Here’s an example that I’ll revisit later:

result {
let! config1 = getConfig "config1"
let! config2 = getConfig "config2"
let! config3 = getConfig "config3"

return {
config1: config1
config2: config2
config3: config3
}
}

In this example, we are getting some configuration from the environment, creatively named ‘config1’, ‘config2’ and ‘config3’.
At the end of the expression, we are returning the full config object which includes all of the required configuration.
The happy path is clear to see and is not cluttered with any error handling, but we haven’t neglected the error cases.
If one of the getConfig calls fails, the magic of the computation expression and its special ‘let!’ keyword kicks in and returns the Error.
The type of this expression is a Result, which can either be the config object or an error.

If you’d like to learn more about the result computation expression and about error handling in F# I recommend ‘Domain Modeling Made Functional’ by Scott Wlaschin, which I found a great resource while learning myself.

Aggregate Results

With a computation expression based on the Result type we gain a neat syntax for handling multiple possible errors, but imagine a scenario in which many of these errors occur. The expression used in the configuration example above would only return the first error encountered, whereas a different error handling method (perhaps using an AggregateException type containing multiple exceptions) would be able to return all of those errors together. This a common scenario for input validation, it’s often impractical to receive exactly one error at a time rather than a list that can all be fixed at once. The dream is to combine the advantages of each approach and this is what the aggregate result computation expression that I am introducing in this article aims to achieve.

Let’s revisit the computation expression from before, but this time I’ll use an aggregate result:

aggregateResult {
let! config1 = getConfig "config1"
and! config2 = getConfig "config2"
and! config3 = getConfig "config3"

return {
config1: config1
config2: config2
config3: config3
}
}

This is just as readable as before and the error handling is still hidden from the reader.
The ‘result’ has been replaced with ‘aggregateResult’ which switches the computation expression being used. We are also now using the ‘and!’ keyword, which was introduced in F# 5.
We start with one ‘let!’ and then follow this with any number of ‘and!’ statements to achieve our aim of combining all of the errors which occur into a list.

But how does this work?

Computation expressions are effectively blueprints rather than code that gets executed exactly as it is written, they are a deeper topic than the intuitive explanation I gave in the previous section. This can be a confusing concept, so a simplified way of thinking about the result expression is to imagine it building up a chain of result functions (like Result.map and Result.bind in the first example) with each ‘let!’ you use. Functions that are chained together like this can only return the first error they encounter, however, the aggregate result expression allows us to instead create a blueprint that can evaluate multiple consecutive statements with the ‘and!’ syntax, then continue to evaluate the remainder of the expression only if all are successful. ‘and!’ is responsible for evaluating the statement similarly to ‘let!’, but in the case of an error it also merges the current error with the previous collection or errors. For us to merge errors into a list we need to use an error type which is also a list, for this I’ve defined a type called ‘AggregateError’.

type AggregateError = private AggregateError of AppError list

The type ‘AppError’ here will need to be a standardised type for representing the errors that can occur in your code base.

Now for the important part for anyone interested in adopting this for their projects, here is the part of the computation expression which does the magic:

member _.MergeSources
(
t1: Result<'T, AggregateError>,
t2: Result<'T2, AggregateError>
) : Result<'T * 'T2, AggregateError> =
match t1, t2 with
| Ok ok1, Ok ok2 -> Ok(ok1, ok2)
| Ok _, Error error2 -> Error error2
| Error error1, Ok _ -> Error error1
| Error error1, Error error2 -> Error(mergeErrors error1 error2)

This is a member of the builder type used to create the computation expression, adding it to the definition of ResultBuilder converts it to the more sophisticated AggregateResultBuilder and enables the usage of ‘and!’. Here we are constraining ourselves to work with result types that have AggregateError as their error type and we are implementing how the computation expression will merge errors. In the case of both the current and previous results being errors we can call a function to merge them via concatenation:

let mergeErrors (AggregateError a1) (AggregateError a2) = 
AggregateError(a1 @ a2)

Pairs of error lists can be combined any number of times meaning we can use ‘and!’ as much as we want in a row. Here’s a visualisation of how the errors from the example get merged at each stage, after the second line is executed we get a list with two errors and after the third line is executed we get a list with three errors.

1*q2PqXWZvDJY35h3KUzW55A.jpeg

Some more technical details

I’ve now explained how aggregate results work, but there are still a few implementation details that are worth mentioning.

  • Private AggregateError

You may have noticed my definition of AggregateError used a private case label so that it can’t be directly created outside of the module that it is defined in. This is because for each error, I wanted only one way to put it into an AggregateError and for that method to remove the clutter involved in wrapping an error in a list. For example:

let unexpectedError message =
[ UnexpectedError message ] |> AggregateError

let myAggregateError = unexpectedError "something went wrong"
  • Unwrapped error lists

It’s worth noting that it’s also possible to not use an AggregateError type at all and instead reference AppError lists directly in the definition of the computation expression builder, this would seemingly remove some of the complexity. The downside to directly using lists of errors is that the added flexibility can allow the mechanism to be used in unintended ways, the method with a private AggregateError case shown above means that empty lists of app errors can’t be created for example. There are benefits to either method, however, we wouldn’t want to make a builder which is flexible enough to take lists of any type of error, as this would result in a whole extra dimension of complexity where we must map errors to the same types before they can be merged.

  • Failing fast

In the configuration example, we’d always want to attempt each individual ‘getConfig’ call as these are fast operations and we’d rather execute them and give the error feedback than wait until the first error is fixed.
If we wanted to fail fast, however, due to operations taking longer (e.g. requiring network calls) we still have the flexibility to do this.

aggregateResult {
let! result1 = nonNetworkCall1
and! result2 = nonNetworkCall2
let! networkResponse = networkCall result1 result2

...
}

This example will return the aggregated errors after the second call and only progress to the network call if both were successful.

Another reason to switch back to a ‘let!’ is if we depend on the result of a previous operation. If we used ‘and!’ for network response we wouldn’t be able to reference ‘result1’ or ‘result2’ as the builder needs to be able to execute each consecutive statement independently in case one of them returns an error.

Summary

This is a simple concept and revolves around adding just one member (‘_.MergeSources’) to the definition of a normal result computation expression builder.

With this enhancement, result builders can merge errors into lists while still hiding the error handling from the reader.

Code bases that already use result expressions are perfect candidates for adopting this approach.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK