Ok, I think John was right about IO bifunctor..

Ok, I think John was right about IO bifunctor..

A while ago there a post on how IO in scalaz had both a F and an E to reflect the error type that is more specific than Throwable. Cat has MonadError and ApplicativeError that assumes the error is a Throwable. That has alot of implications all up and down the APIs. Just as F has to be everywhere, once you want to customize the error to be more specific than Throwable or not related to Throwable at all much like as described in https://typelevel.org/blog/2018/11/28/http4s-error-handling-mtl-2.html, you have to fix all the APIs all the way down.

That means the post http://degoes.net/articles/bifunctor-io from John was kind of right in my eyes. Once you do the work to be anything more specific, you might as well be explicit everywhere since one the flood gates open, that’s it. You can always be less specific.

I ran into this issue when creating some specialized scala HTTP client’s that have a wide variety of algebras and the “HTTP” layer needed customizations so that it propagated errors that were consistent with the upper “odata” protocol layer. While I could have faked it, I thought I would try to drive the types and error handling all the way down, and it took me awhile to work out the abstractions.

So now, with F[_]] already in my type parameters, it is joined by E. Which does not work well as I would rather have F[E,A].

Note there are thoughts to the contrary here.

Arguments for and against this design suggest that in the end, it may be that application specific needs motivate its usefulness. However, if it is useful in enough places, you may need this capability as a default in order to use it all situations. I have seen arguments against this design but I do not agree with degree of importance around the assumptions in the counter arguments or around the application types and runtime environments (JVM vs JS).

It is also possible that the bifunctor IO design becomes more viable when new scala capabilities are introduce, such has union types. It’s possible that we need a macro that takes a list of types and “subtracts” one from it e.g. to allow different layers to add/remove errors to a type more easily than today. Note that John’s ZIO uses concepts borrowed from Akka and supervisory hierarchies to help manage error hierarchies. Seems innovative at the toplevel at least.

Declaring Error Intent with Types - the important part of bifunctor IO

It’s clear that an API can lie. You could declare a IO[E,A] with an E = Nothing (fs2 uses Pure as the bottom) but still have that IO throw something you were not expecting that cause the IO to fail in an “unchecked way.” Because the failure was unexpected according to the type, its a defect that the programmer must resolve. It’s not a recoverable error.

Suppose you declare your IO to be IO[Nothing,A]. If there is a throw that you did not handle as a programmer then the run time system for ZIO will have an exit status of “failed” with a Cause of Unchecked. If we had handed the error inside our code and translated that to a A our type is good and we can return that as an ExitStatus of Succeeded. If the IO fails with another error E (remember this E is not in our IO[Nothing,A]), we may want to “fail” the IO with a Cause of Checked E. But since the IO type just mentioned is IO[Nothing,A], we can’t return an Checked[E].

So if your E=Nothing the only ExitStatus[Nothing,A] you can create is a Unchecked error, which has type Cause[Nothing], or an Interruption concept, which is also of type Cause[Nothing].

When we say “return a value” in the asynchronous case, we use the runtime system method unsafeRunAsync to run the IO:

trait RTS { 
  def unsafeRunAsync[E, A](io: IO[E, A])(k: (ExitResult[E, A])Unit): Unit
}

If the IO we are running is IO[Nothing,A] then our “callback” can only be a ExitResult[Nothing,A] => Unit. The presence of Nothing therefore indicates that the only way the IO can fail is by the IO being interrupted or an unchecked exception is thrown.

Contrast to Cats

In contrast, if we used cats IO[A]. We know that this IO can have an exception in it. But its quite possible that the IO has been constructed so that no Throwable will occur or that any Throwable is translated into an A. Even if the error handling had already attached a IO.recover the signature would still be IO[A]. The idea is that an E in IO can communicate more information about failure handling in your program better.

In cats, we could also just have everything bundled up under Throwable. If we have data that needs to be pattern matched out of the exception we could also use a well-known exception. In highly interactive programs, such as a web program, I’m finding that errors are quite common, either do to communication failures or data “state” errors (lots of unexpected statuses). So errors are much more common. It’s a bit easier to have that E be explicit.

Is this a real issue. To be honest, I ran into these lack of clarity (ability to reason) and confusion about what’s in that effect very quickly when writing code for a web app. I thought that for all the trouble I went through to use types more smartly, this was a gaping, awkward hole in place where I have alot of code–error handling.

PS

  • I put in a E into a library that I am writing and pulled it out. I did not have a true bifunctor IO design and tried to fake it but that did not work out well in the end so I’m back to the implied Throwable design. The exercise was worth it though.

  • I am playing with ZIO on github to understand the bifuctor IO value proposition.

Comments

Popular posts from this blog

zio layers and framework integration

typescript and react types

dotty+scala.js+async: interesting options