Suppose you have a few modules in an application which needs to request and parse data from a JSON api:
- Http
- Json parsing
- Business logic, something to do with Images
Each of these can fail in their own way:
- Http errors -- SocketError, Timeout, NotFound, BadGateway, EmptyBody
- Json parsing -- MissingField, UnexpectedType
- Business logic -- WrongDimensions, WrongFormat
As much as possible, you want to these modules to be independent. The image validation shouldn't know or have anything to do with Http details. (An image however will need to be able to be constructed from JSON.)
Each module will need it's own error data type to capture its particular failures. Here's a sketch of these modules, the error data types and the functions that use them:
module Http where
data HttpError = SocketError | Timeout | NotFound | BadGateway
execute :: Request -> Either HttpError Response
module Json where
data JsonError = MissingField String | UnexpectedType String String
parse :: JsonParser a -> String -> Either JsonError a
module Image where
data ImageError = WrongFormat | WrongDimensions
validateImage :: Image -> Either ImageError Image
parseImage :: JsonParser Image
What happens when we try to glue these components together in our app?
ex1 :: Request -> Either ??? Image
ex1 imageRequest = execute imageRequest >>= parse parseImage >>= validateImage
It won't compile. Each of our modules use a different error type, so we cannot bind between them. We need to do 2 things:
- Create an application-level error data type, that can hold any of the errors for any of the subcomponents
Lift
each function in each component so that it produces errors that are wrapped up in our application level data type
data AppError = H HttpError | J JsonError | I ImageError
-- given, first :: (e -> q) -> Either e a -> Either q a
liftHttpError :: Either HttpError a -> Either AppError a
liftHttpError = first H
liftJsonError :: Either JsonError a -> Either AppError a
liftJsonError = first J
liftImageError :: Either ImageError a -> Either ImageError a
liftImageError = first I
Now our request pipeline looks like this:
ex1 :: Request -> Either AppError Image
ex1 imageRequest = liftHttpError (execute imageRequest) >>= (liftJsonError . parse parseImage) >>= (liftImageError . validateImage)
-- Again with do notation, just for comparison
ex1' :: Request -> Either AppError Image
ex1' =
do r <- liftHttpError (execute imageRequest)
j <- liftJsonError (parse parseImage r)
liftImageError (validateImage j)
Okay! So far not too bad. We have modules that are independent (they have with their own specific error types, and compile independently of one another), and when we compose up at the application level we have a mechanism for bringing everything together.
But, consider the case where we want to factor our application into 2 parts: one that does the http request (liftHttpError (execute imageRequest)
), and the other half that parses the json and validates the image ((liftJsonError . parse parseImage) >>= (liftImageError . validateImage)
).
p1 :: Either HttpError Request
p1 = execute imageRequest
p2 :: Request -> Either ??? Image
p2 r = liftJsonError (parse parseImage r) >>= (liftImageError . validateImage)
So we're back a step again. We could give p2
an error type of AppError
, but that includes the possibility of failure with HttpError
.
@johnmay10000 Yeah I think so. :)