Exceptions vs. Return Values to represent errors (in F#) – II– An example problem - Luca Bolognese

Exceptions vs. Return Values to represent errors (in F#) – II– An example problem

Luca -

☕ 3 min. read

In the pre­vi­ous post, we talked about the dif­fer­ence be­tween Critical and Normal code. In this post we are go­ing to talk about the Critical code part. Ideally, we want:

  • A way to in­di­cate that a par­tic­u­lar piece of code (potentially the whole pro­gram) is Critical
  • A way to force/​en­cour­age the pro­gram­mer to make an ex­plicit de­ci­sion on the call site of a func­tion on how he wants to man­age the er­ror con­di­tions (both con­tin­gen­cies and faults)
  • A way to force/​en­cour­age the pro­gram­mer to ex­pose con­tin­gen­cies/​faults that are ap­pro­pri­ate for the con­cep­tual level of the func­tion the code is in (aka don’t ex­pose im­ple­men­ta­tion de­tails for the func­tion,  i.e. don’t throw SQLException from a ge­tUser method where the caller is sup­posed to catch it)

Remember that I can use the word force’ here be­cause the pro­gram­mer has al­ready taken the de­ci­sion to analyse each line of code for er­ror con­di­tions. As we dis­cussed in the pre­vi­ous post, In many/​most cases, such level of scrutiny is un­war­ranted.

Let’s use the be­low sce­nario to un­ravel the de­sign:

type User = {Name:string; Age:int}
let fetchUser userName =
    let userText            = dbQuery (userName + ".user")
    let user                = parseUser(userText)
    user

This looks like a very rea­son­able .NET func­tion and it is in­deed rea­son­able in Normal code, but not in Critical code. Note that the caller likely needs to han­dle the user-not-in-repos­i­tory case be­cause there is no way for the caller to check such con­di­tion be­fore­hand with­out in­cur­ring the per­for­mance cost of two net­work roundtrips.

Albeit the beauty and sim­plic­ity, there are is­sues with this func­tion in a Critical con­text:

  • The func­tion throws im­ple­men­ta­tion re­lated ex­cep­tions, break­ing en­cap­su­la­tion when the user needs to catch them
  • It is not clear from the code if the de­vel­oper thought about er­ror man­age­ment (do you think he did?)
  • Preconditions are not checked, what about empty or null strings?

To test our de­sign let’s de­fine a fake db­Query:

let dbQuery     = function
    | "parseError.user"     -> "parseError"
    | "notFound.user"       -> raise (FileNotFoundException())
    | "notAuthorized.user"  -> raise (UnauthorizedAccessException())
    | "unknown.user"        -> failwith "Unknown error reading the file"
    | _                     -> "FoundUser"

The first two ex­cep­tions are con­tin­gen­cies, the caller of fetchUser is sup­posed to man­age them. The un­known.user ex­cep­tion is a fault in the im­ple­men­ta­tion. parseEr­ror trig­gers a prob­lem in the parseUser func­tion.

ParseUser looks like this:

let parseUser   = function
    | "parseError"          -> failwith "Error parsing the user text"
    | u                     -> {Name = u; Age = 43}

Let’s now cre­ate a test func­tion to test the dif­fer­ent ver­sions of fetchUser that we are go­ing to cre­ate:

let test fetchUser =
    let p x                 = try printfn "%A" (fetchUser x) with ex -> printfn "%A %s" (ex.GetType()) ex.Message
    p "found"
    p "notFound"
    p "notAuthorized"
    p "parseError"
    p "unknown"

Running the func­tion ex­poses the prob­lems de­scribed above. From the point of view of the caller, there is no way to know what to ex­pect by just in­spect­ing the sig­na­ture of the func­tion. There is no dif­fer­en­ti­a­tion be­tween con­tin­gen­cies and faults. The only way to achieve that is to catch some im­ple­men­ta­tion-spe­cific ex­cep­tions.

How would we trans­late this to Critical code?

First, we would de­fine a type to rep­re­sent the re­sult of a func­tion:

type Result<'a, 'b> =
| Success of 'a
| Failure of 'b

This is called the Either type, but the names have been cus­tomized to rep­re­sent this sce­nario. We then need to de­fine which kind of con­tin­gen­cies our func­tion could re­turn.

type UserFetchError =
| UserNotFound  of exn
| NotAuthorized of int * exn

So we as­sume that the caller can man­age the fact that the user is not found or not au­tho­rized. This type con­tains an Exception mem­ber.  This is use­ful in cases where the caller does­n’t want to man­age a con­tin­gency, but wants to treat it like a fault (for ex­am­ple when some Normal code is call­ing some Critical code).

In such cases, we don’t lose im­por­tant de­bug­ging in­for­ma­tion. But we still don’t break en­cap­su­la­tion be­cause the caller is not sup­posed to catch’ a fault.

Notice that NotAuthorized con­tains an int mem­ber. This is to show that con­tin­gen­cies can carry some more in­for­ma­tion than just their type. For ex­am­ple, a caller could match on both the type and the ad­di­tional data.

With that in place, let’s see how the pre­vi­ous func­tion looks like:

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"))
                        with
                        | 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))
        user
    | Failure(ex)       -> Failure(ex)

Here is what changed:

  • Changed name to tryXXX to con­vey the fact that the method has con­tin­gen­cies
  • Added pre­con­di­tion test, which gen­er­ates a fault
  • The sig­na­ture of the func­tion now con­veys the con­tin­gen­cies that the user is sup­posed to know about

But still, there are prob­lems:

  • The code be­came very long and con­vo­luted ob­fus­cat­ing the suc­cess code path
  • Still, has the de­vel­oper thought about the er­ror con­di­tions in parseUser and de­cided to let ex­cep­tions get out, or did she for­get about it?

The re­turn value crowd at this point is go­ing to shout: Get over it!! Your code does­n’t need to be el­e­gant, it needs to be cor­rect!”. But I dis­agree, ob­fus­cat­ing the suc­cess code path is a prob­lem be­cause it be­comes harder to fig­ure out if your busi­ness logic is cor­rect. It is harder to know if you solved the prob­lem you set out to solve in the first place.

In the next post we’ll see what we can do about keep­ing beauty and be­ing cor­rect.

0 Webmentions

These are webmentions via the IndieWeb and webmention.io.