Exceptions vs. Return Values to represent errors (in F#) – III–The Critical monad - Luca Bolognese

Exceptions vs. Return Values to represent errors (in F#) – III–The Critical monad

Luca -

☕ 4 min. read

Code for this post is here.

In the last post we looked at some Critical code and de­cided that, al­beit cor­rect, it is con­vo­luted. The er­ror man­age­ment path ob­fus­cates the un­der­ly­ing logic. Also we have no way of know­ing if a de­vel­oper had thought about the er­ror path or not when in­vok­ing a func­tion.

Let’s tackle the lat­ter con­cern first as it is eas­ier. We want the de­vel­oper to de­clar­a­tively tag each method call with some­thing that rep­re­sents his in­tent about man­ag­ing the Contingencies or Faults of the func­tion.  Moreover if the func­tion has con­tin­gen­cies, we want to force the de­vel­oper to man­age them ex­plic­itly.

We can­not use at­trib­utes for this as func­tion calls hap­pen in the mid­dle of the code, so there is no place to stick at­trib­utes into. So we are go­ing to use higher level func­tions to wrap the func­tion calls.

The first case is easy. If the de­vel­oper thinks that the caller of his code has no way to re­cover from all the ex­cep­tions thrown by a func­tion, he can prepend his func­tion call with the fault’ word as in:

fault parseUser userText

That sig­nals read­ers of the code that the de­vel­oper is will­ing to prop­a­gate up all the ex­cep­tions thrown by the func­tion parseUser. Embarrassingly, fault’ is im­ple­mented as:

let fault f = f

So it is just a tag. Things get trick­ier when the func­tion has con­tin­gen­cies. We want to find a way to man­age them with­out in­tro­duc­ing un­due com­plex­ity in the code.

We’d like to catch some ex­cep­tions thrown by the func­tion and con­vert them to re­turn val­ues and then ei­ther re­turn such re­turn val­ues or man­age the con­tin­gency im­me­di­ately af­ter the func­tion call. On top of that, we’d want all of the code writ­ten af­ter the func­tion call to ap­pear as clean as if no er­ror man­age­ment were tak­ing place. Monads (computation val­ues) can be used to achieve these goals.

Last time we in­tro­duced a type to rep­re­sent er­ror re­turn val­ues:

type Result<'a, 'b> =
| Success of 'a
| Failure of 'b
type UserFetchError =
| UserNotFound  of exn
| NotAuthorized of int * exn 

We can then cre­ate a com­pu­ta­tion ex­pres­sion that abstracts out’ the Failure case and let you write the code as cleanly as if you were not han­dling er­rors. Let’s call such thing critical’. Here is how the fi­nal code looks like:

let tryFetchUser3 userName =
    if String.IsNullOrEmpty userName then invalidArg "userName" "userName cannot be null/empty"
    critical {
        let Unauthorized (ex:exn) = NotAuthorized (ex.Message.Length, ex)
        let! userText = contingent1
                            [FileNotFoundException()        :> exn, UserNotFound;
                             UnauthorizedAccessException()  :> exn, Unauthorized]
                            dbQuery (userName + ".user")
        return fault parseUser userText

You can com­pare this with the code you would have to write with­out the critical’ li­brary (from last post):

let tryFetchUser1 userName =
    if String.IsNullOrEmpty userName then invalidArg "userName" "userName cannot be null/empty"
    // Could check for file existence in this case, but often not (i.e. db)
    let userResult =    try
                            Success(dbQuery(userName + ".user"))
                        | FileNotFoundException as ex        -> Failure(UserNotFound ex)
                        | UnauthorizedAccessException as ex  -> Failure(NotAuthorized(2, ex))
                        | ex                                    -> reraise ()
    match userResult with
    | Success(userText) ->
        let user        = Success(parseUser(userText))
    | Failure(ex)       -> Failure(ex)

And with the orig­i­nal (not crit­i­cal) func­tion:

let fetchUser userName =
    let userText            = dbQuery (userName + ".user")
    let user                = parseUser(userText)

Let’s go step by step and see how it works. First of all, you need to en­close the Critical parts of your code (perhaps your whole pro­gram) in a critical’ com­pu­ta­tion:

    critical {

This al­lows you to call func­tions that re­turn a Result and man­age the re­turn re­sult as if it were the suc­cess­ful re­sult. If an er­ror were gen­er­ated, it would be re­turned in­stead. We will show how to man­age con­tin­gen­cies im­me­di­ately af­ter the func­tion call later.

The above is il­lus­trated by the fol­low­ing:

        let! userText = contingent1
                            [FileNotFoundException()        :> exn, UserNotFound;
                             UnauthorizedAccessException()  :> exn, Unauthorized]
                            dbQuery (userName + ".user")

Here contingent1’ is a func­tion that re­turns a Result, but user­Text has type string. The Critical monad, and in par­tic­u­lar the us­age of let!’ is what al­lows the magic to hap­pen.

contingentN’ is a func­tion that you call when you want to man­age cer­tain ex­cep­tions thrown by a func­tion as con­tin­gen­cies. The N part rep­re­sents how many pa­ra­me­ters the func­tion takes.

The first pa­ra­me­ter to contingent1’ is a list of pairs (Exception, ErrorReturnConstructor). That means: when an ex­cep­tion of type Exception is thrown, re­turn the re­sult of call­ing ErrorReturnConstructor(Exception)’ wrapped in­side a Failure’ ob­ject. The sec­ond pa­ra­me­ter to contingent1’ is the func­tion to in­voke and the third is the ar­gu­ment to pass to it.

Conceptually, ContingentN’ is a tag that says: if the func­tion throws one of these ex­cep­tions, wrap them in these re­turn val­ues and prop­a­gate all the other ex­cep­tions. Notice that Unauthorized takes an in­te­ger and an ex­cep­tion as pa­ra­me­ters while the ErrorReturnConstructor takes just an ex­cep­tion. So we need to add this line of code:

        let Unauthorized (ex:exn) = NotAuthorized (ex.Message.Length, ex) 

After the con­tin­gent1 call, we can then write code as if the func­tion re­turned a nor­mal string:

        return fault parseUser userText

This achieves that we set up to do at the start of the se­ries:

  • Contingencies are now ex­plicit in the sig­na­ture of tryFetchUser3
  • The de­vel­oper needs to in­di­cate for each func­tion call how he in­tend to man­age con­tin­gen­cies and faults
  • The code is only slightly more com­plex than the non-crit­i­cal one

You can also de­cide to man­age your con­tin­gen­cies im­me­di­ately af­ter call­ing a func­tion. Perhaps there is a way to re­cover from the prob­lem. For ex­am­ple, if the user is not in the data­base, you might want to add a stan­dard one:

let createAndReturnUser userName = critical { return {Name = userName; Age = 43}}
</font>let </span>tryFetchUser4 userName = if String.IsNullOrEmpty userName then invalidArg "userName" "userName cannot be null/empty" critical { let Unauthorized (ex:exn) = NotAuthorized (ex.Message.Length, ex) // depends on ex let userFound = contingent1 [FileNotFoundException() :> exn, UserNotFound; UnauthorizedAccessException() :> exn, Unauthorized] dbQuery (userName + ".user") match userFound with | Success(userText) -> return fault parseUser userText | Failure(UserNotFound(_)) -> return! createAndReturnUser(userName) | Failure(x) -> return! Failure(x) }

The only dif­fer­ence in this case is the us­age of let’ in­stead of let!’. This ex­poses the real re­turn type of the func­tion al­low­ing you to pat­tern match against it.

Sometimes a sim­ple ex­cep­tion to re­turn value map­ping might not be enough and you want more con­trol on which ex­cep­tions to catch and how to con­vert them to re­turn val­ues. In such cases you can use con­tin­gent­Gen:

let tryFetchUser2 userName =
    if String.IsNullOrEmpty userName then invalidArg "userName" "userName cannot be null/empty"
    critical {
        let! userText = contingentGen
                            (fun ex -> ex FileNotFoundException || ex UnauthorizedAccessException)
                            (fun ex ->
                                match ex with
                                       | FileNotFoundException       -> UserNotFound(ex)
                                       | UnauthorizedAccessException -> NotAuthorized(3, ex)
                                       | _ -> raise ex)
                            (fun _ -> dbQuery (userName + ".user"))
        return fault parseUser userText

The first pa­ra­me­ter is a lambda de­scrib­ing when to catch an ex­cep­tion. The sec­ond lambda trans­late be­tween ex­cep­tions and re­turn val­ues. The third lambda rep­re­sents which func­tion to call.

Sometimes you might want to catch all the ex­cep­tions that a func­tion might throw and con­vert them to a sin­gle re­turn value:

type GenericError = GenericError of exn
 // 1. Wrapper that prevents exceptions for escaping the method by wrapping them in a generic critical result
let tryFetchUserNoThrow userName =
    if String.IsNullOrEmpty userName then invalidArg "userName" "userName cannot be null/empty"
    critical {
        let! userText = neverThrow1 GenericError dbQuery (userName + ".user")
        return fault parseUser userText

And some­times you might want to go the op­po­site way. Given a func­tion that ex­poses some con­tin­gen­cies, you want to trans­late them to faults be­cause you don’t know how to re­cover from them.

let operateOnExistingUser userName =
    let user = alwaysThrow GenericException tryFetchUserNoThrow userName

Next time we’ll look at how the Critical com­pu­ta­tion ex­pres­sion is im­ple­mented.

0 Webmentions

These are webmentions via the IndieWeb and webmention.io.