Kevin Li
Kevin Li

Reputation: 323

Elegant Solution for Discriminated Unions Representing Failures in F#

I’m moving away from creating and catching exceptions in F# to something built around Result<'T, 'TError>. I found this, which agrees with my initial pursuit of representing failures with a discriminated union, but I ran into the problem of having a lot of different cases for my Failure discriminated union:

type TypedValue =
| Integer of int
| Long of int64
| …

type Failure =
| ArgumentOutOfRange of {| Argument : TypedValue; Minimum : TypedValue; Maximum : TypedValue |}
| BufferTooSmall of {| RequiredSize : int |}
| Exception of exn
| IndexOutOfRange of {| Index : int |}
| …

I’d prefer not to have a multitude of types dedicated to error handling. This “typed value” thing is not elegant at all as I either have to create conflicting names (Byte versus System.Byte) or create long names to avoid conflict (| UnsignedByte of byte).

Generics is a possibility, but then what would the 'T in Failure<'T> represent? ArgumentOutOfRange wouldn’t be the only case in the discriminated union, and some cases might require more type parameters or none at all.

Upvotes: 2

Views: 340

Answers (2)

Aaron M. Eshbach
Aaron M. Eshbach

Reputation: 6510

Another option (and what I normally do, personally) is to model your domain-specific failures with specific cases in your Failure union, and then have a general-purpose UnexpectedError case that takes an exn as its data and handles any non-domain-related failures. Then, when an error from one domain occurs in another, you can use Result.mapError to convert between them. Here's an example from a real domain I've modeled:

open System

// Top-level domain failures
type EntityValidationError =
| EntityIdMustBeGreaterThanZero of int64
| InvalidTenant of string
| UnexpectedException of exn

// Sub-domain specific failures
type AccountValidationError =
| AccountNumberMustBeTenDigits of string
| AccountNameIsRequired of string
| EntityValidationError of EntityValidationError // Sub-domain representaiton of top-level failures
| AccountValidationUnexpectedException of exn

// Sub-domain Entity
// The fields would probably be single-case unions rather than primitives 
type Account =
    {
        Id: int64 
        AccountNumber: string
    }

module EntityId =
    let validate id =
        if id > 0L
        then Ok id
        else Error (EntityIdMustBeGreaterThanZero id)

module AccountNumber =
    let validate number =
        if number |> String.length = 10 && number |> Seq.forall Char.IsDigit
        then Ok number
        else Error (AccountNumberMustBeTenDigits number)

module Account =
    let create id number =
        id 
        |> EntityId.validate
        |> Result.mapError EntityValidationError // Convert to sub-domain error type
        |> Result.bind (fun entityId ->
            number 
            |> AccountNumber.validate
            |> Result.map (fun accountNumber -> { Id = entityId; AccountNumber = accountNumber }))

Upvotes: 1

Tomas Petricek
Tomas Petricek

Reputation: 243096

Using Result<'T, 'TError> makes a lot of sense in cases where you have custom kinds of errors that you definitely need to handle or in cases where you have some other logic for propagating errors than the one implemented by standard exceptions (e.g. if you can continue running code despite the fact that there was an error). However, I would not use it as a 1:1 replacement for exceptions - it will just make your code unnecessarilly complicated and cumbersome without really giving you much benefits.

To answer your question, since you are mirroring standard .NET exceptions in your discriminated union, you could probably just use a standard .NET exception in your Result type and use Result<'T, exn> as your data type:

if arg < 10 then Error(ArgumentOutOfRangeException("arg", "Value is too small"))
else OK(arg - 1)

Regarding the ArgumentOutOfRange union case and TypedValue - the reason for using something like TypedValue is typically that you need to pattern match on the possible values and do something with them. In case of exceptions, what do you want to do with the values? If you just need to report them to the user, then you can use obj which will let you easily print them (it won't be that easy to get the numerical values and do some further calculations with them, but I don't think you need that).

type Failure = 
  | ArgumentOutOfRange of {| Argument : obj; Minimum : obj; Maximum : obj |}

Upvotes: 3

Related Questions