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

-

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:

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:

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:

But still, there are prob­lems:

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.

Tags