Skip to content

Instantly share code, notes, and snippets.

@nkpart
Last active May 6, 2016 12:07
Show Gist options
  • Save nkpart/7777755b79cbfe6bd089 to your computer and use it in GitHub Desktop.
Save nkpart/7777755b79cbfe6bd089 to your computer and use it in GitHub Desktop.

Modules, Errors and Coupling

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.

@ryanbooker
Copy link

I did a version of this that made all those stages output "AppError" instead. But rereading and thinking about reusable components, would you actually leave the stages as is, and have something else that "Lifts" the stage up into AppError space? A function from each StageError -> AppError?

@ryanbooker
Copy link

A question re the execute function. I assume the Response is the HTTP response and accompanying data, or just the data. How would you envisage handling the case of endpoints that don't return data? e.g. 204 (I think) signifies success with no accompanying data.

@ryanbooker
Copy link

Also execute is synchronous. How would you fit the pieces today to make it asynchronous? Would you make the request synchronous and manually wrap the whole chain (whatever it ends up being) in something that runs it in the background?

UPDATE: I've currently been handling this by viewing the input of the callback on the async request method as the return type of execute. i.e. in Swift something like func execute(request: Request, completion: Either<HTTPError, Response> -> ()) -> (). This means that the chaining has to happen in side that callback though.

@nkpart
Copy link
Author

nkpart commented Mar 5, 2016

Update should answer your 1st question :)

Re 204: For this example it doesn't really matter, it could be http requests and response, or it could be some other protocol and it wouldn't change very much. It's still up to you to lift the protocol up into your own apps data types. ie. escape the protocol early. In the http libs I've used 204 just means you have empty body. So maybe you would parse it to ()?

With async, I would add promises/futures/asyncs and wrap it that way.

execute :: Request -> Async (Either HttpError Response)

This is the EitherT transformer:

newtype EitherT e m a = EitherT { runEitherT :: m (Either e a) }
execute :: Request -> EitherT HttpError m a

EitherT is a Monad/Applicative/Functor and more, so you get a whole lot of functions to use out of the box.

If you go this way, you need to complicate your lift functions again to add the Async if it doesn't exist yet. i.e. liftJsonError :: Either JsonError a -> EitherT AppError Async a. This post is slowly working it's way towards a neater solution to that too :)

@ryanbooker
Copy link

Just as a note: The execute side of the fence in Cocoa, has two possible errors. NSError or HTTPError. The whole request can fail with an NSError, or it can fail in the normal HTTP ways.

Would you just combine that pair of types for simplicity, e.g. add data HTTPError = Error NSError | SocketError | TimeOut | ... or create data NetworkingError = HTTPError | NSError.

@ryanbooker
Copy link

And I eagerly away the answer to the Part 2 Cliffhanger. :)

@johnmay10000
Copy link

Hey,

Should it be this?

liftImageError :: Either ImageError a -> Either AppError a
liftImageError = first I

@ryanbooker
Copy link

ryanbooker commented May 6, 2016

@johnmay10000 Yeah I think so. :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment