Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I compose error types in Haskell?

Is there an agree-on best practice for aggregating and handling typed errors across many layers of functions in a larger Haskell application?

From introductory texts and the Haskell Wiki, I take that pure functions should be total - that is, evaluate to errors as part of their co-domain. Runtime exceptions cannot be completely avoided, but should be confined to IO and asynchronous computations.

How do I structure error handling in pure, synchronous functions? The standard advice is to use Either as return type, and then define an algebraic data type (ADT) for the errors a function might result in. For example:

data OrderError
    = NoLineItems
    | DeliveryInPast
    | DeliveryMethodUnavailable

mkOrder :: OrderDate -> Customer -> [lineIntem] -> DeliveryInfo -> Either OrderError Order

However, once I try to compose multiple error-producing functions together, each with their own error type, how do I compose the error types? I would like to aggregate all errors up to the UI layer of the application, where the errors are interpreted, potentially mapped to locale-specific error messages, and then presented to the user in a uniform way. Of course, this error presentation should not interfere with the functions in the domain ring of the application, which should be pure business logic.

I don't want to define an uber-type - one large ADT that contains all possible errors in the application; because that would mean (a) that all domain-level code would need to depend on this type, which destroys all modularity, and (b) this would create error types that are too large for any given function.

Alternatively, I could define a new error type in each combining function, and then map the individual errors to the combined error type: Say funA comes with error-ADT ErrorA, and funB with ErrorB. If funC, with error type ErrorC, applies both funA and funB, funC needs to map all error-cases from ErrorA and ErrorB to new cases that are all part of ErrorC. This seems to be a lot of boilerplate.

A third option could be that funC wraps the errors from funA and funB:

data ErrorC
    = SomeErrorOfFunC
    | ErrorsFromFunB ErrorB
    | ErrorsFromFunA ErrorA

In this way, mapping gets easier, but the error handling in the UI-ring needs to know about the exact nesting of functions in the inner rings of the application. If I refactor the domain ring, I do need to touch the error unwrapping function in the UI.

I did find a similar question, but the answer using Control.Monad.Exception seems to suggest runtime exceptions rather than error return types. A detailed treatment of the problem seems to be this one by Matt Parson. Yet, the solution involves several GHC extensions, type-level programming and lenses, which is quite a lot of stuff to digest for a newbie like me, who simply wants to write a decent application with proper "by the book" error handling using Haskell's expressive type system.

I heard that PureScript's extensible record would allow to combine error enums more easily. But in Haskell? Is there a straightforward best practice? If so, where can I find some documentation or tutorial on how to do it?

like image 690
Ulrich Schuster Avatar asked May 17 '20 12:05

Ulrich Schuster


People also ask

What is error Haskell?

On the one hand, an error is a programming mistake such as a division by zero, the head of an empty list, or a negative index. If we identify an error, we remove it. Thus, we don't handle errors, we simply fix them. In Haskell, we have error and undefined to cause such errors and terminate execution of the program.

Does Haskell have exception handling?

The Haskell runtime system allows any IO action to throw runtime exceptions. Many common library functions throw runtime exceptions. There is no indication at the type level if something throws an exception. You should assume that, unless explicitly documented otherwise, all actions may throw an exception.

How do you use either in Haskell?

In Haskell either is used to represent the possibility of two values. Either is used to represent two values that can be correct or error. It has two constructors also which are named Left and Right. These constructors also represent and show some purpose either in Haskell.


1 Answers

For your aggregatable Error Type, I suggest that you look up validation: A data-type like Either but with an accumulating Applicative.

The library is exactly one module, consisting of only a handful of definitions. The Validation type within the package is essentially (though not literally):

type Validation e a = Either (NonEmpty e) a

What's worth pointing out is that accumulation of errors is achieved using the applicative combinators, namely liftA2, liftA3 and zip. You cannot accumulate errors inside a monad comprehension, aka do notation:

user :: Int -> Validation DomainError User
userId :: User -> Int
post :: Int -> Validation DomainError Post

userAndPost = do 
  u <- user 1
  p <- post . userId $ u
  return $ (u,p)

The applicative version on the other hand, may yield two errors:

userAndPostA2 = liftA2 (,) (user 1) (post 1)    

The monad version of the userAndPost function above can never produce two errors for both user and post not being found. It's always one or the other. Applicatives, while theoretically recognized as being less powerful than monads, have unique advantages in some practices. Another edge that an applicative has over a monad is that of concurrency. Taking the examples above once again, one can easily deduce why monads inside a comprehension can never be executed concurrently (Notice how fetching of the post depends on the user id from the fetched user, thus dictating that execution of one action depends on the result of the other).

As for your concern for breaking code modularity when opting to define a single disjointed union type DomainError for all domain level errors, I'd venture to say there is no better way to model it, provided that the said domain-specific error type is only constructed and passed around by functions in the domain layer. Once say the HTTP layer calls the function from the domain layer it would then need to translate the error from the domain layer to that of its own, e.g via a mapping function similar to:

eDomainToHTTP :: DomainError -> HTTPError
eDomainToHTTP InvalidCredentials = Forbidden403
eDomainToHTTP UserNotFound = NotFound404
eDomainToHTTP PostNotFound = NotFound404

With one such function, you can easily transform any input -> Validation DomainError output to input -> Validation HTTPError output, thus preserving encapsulation and modularity within your codebase.

like image 86
shayan Avatar answered Sep 18 '22 08:09

shayan