Cats-effect: Consider support for unexceptional Effect types

Created on 1 Apr 2018  Â·  19Comments  Â·  Source: typelevel/cats-effect

I realize this is a similar ask as #67, however I propose a somewhat different interface

trait NoErrorSync[F[_]] extends Monad[F] {
  def delayAndCatch[E, A](thunk: => A)(f: Throwable => E): F[Either[E, A]]
}

This would provide us with a way to support effect types that do not have any underlying exceptions, meaning they can never fail, which to me is a pretty good guarantee to have, which is also why I created this unexceptional IO library.
With this I can now distinguish between calls to pure and calls that use the FFI by looking at the type signature:

def foo[F[_]: NoErrorSync, ...](...): F[List[String]]

vs.

def foo[F[_]: Sync, ...](...): F[List[String]]

With the former signature, I can be absolutely sure I'm getting a correct value and don't need to call foo(...).attempt to be sure it is.

Another big issue as to why I think something like this is important is that currently a lot of people already use EitherT in conjunction with an effect type, which now means I've got two ways to handle errors, because EitherT[F, E, A] actually has two MonadError instances, which can cause quite some confusion and in addition also severely complicates things like bracket.

Of course, none of this doesn't mean we shouldn't be throwing all of our current hierarchy overboard and I'm confident we could keep both easily, but it would mean practically duplicating all of our current type classes (save for Bracket), I'm not sure how to solve this problem right now, but as a group we've covered harder problems before. :)

Most helpful comment

I am not going to comment on this specific interface, because I think the goal to have a way to represent unexceptional effects can be separated from a discussion of exactly how to achieve that, but I will comment on the extreme utility of what this proposal is trying to achieve.

There is no difference between the type. As currently stands, the type F[A] under the effect hierarchy means that F[_] can fail with Throwable. There is absolutely no way to represent the notion of a value that cannot fail, or can fail with something other than Throwable. Yes, one can represent F[Either[Throwable, A]] in an attempt to represent a value that cannot fail, but this is meaningless, as even calling attempt on an F[_] provides zero type guarantees that the resulting value will not immediately fail with some other Throwable.

As a consequence of these severe limitations, it becomes impossible to represent the _requirement_ that an effectful computation must _not_ fail for any reason (or, more broadly, must fail for one of N different, strongly-typed reasons under the control of the user).

This problem—which could be called the problem of _dynamic error typing_—is so severe that it has led to numerous bugs in large-scale FP applications I have worked on.

For example, at one point, we have request handlers that must return JSON to the front-end. The goal is that each JSON response is formatted in a specific way that can be interpreted by the front-end. Errors have a specific encoding that the front-end understands and can handle internally or present to the user in an appropriate way. For this reason, we have something like IO[Either[ClientError, Response]] which represents the valid response or the typed error that can eventually be encoded into JSON. However, we have historically had no way to signal that a computation IO[Either[ClientError, Response]] cannot fail. As a result, while programmers have made many attempts to handle all errors and convert them into nice typed error values, sometimes they forget or some unexpected error sneaks in, and we end up with a value of type IO[Either[ClientError, Response]] that fails for some Throwable. We, or rather our users, discover these "hidden errors" randomly as they explore combinations of features and trigger edge cases.

If we had a way to represent an unexceptional effect, then we could have a single interface with the HTTP layer that would require the type to be Unexceptional[Either[ClientError, Response]]. Because the only way to construct one of these would be to handle all Throwable, then we would have a compile-time guarantee that no more Throwable can sneak in and pollute the REST API.

This is one of many cases where the lack of precise typing creates runtime bugs. We are statically-typed functional programmers for a reason—we like to use the compiler to precisely tell us _if_ and _how_ a computation may fail. The current type class hierarchy does not permit us such precision, because there are no design points in the landscape where the _if_ and _how_ of failure can be specified. As a result, the current dynamic error typing is a regression against the principles that have all driven us to statically-typed FP, and should be addressed in a sane way that lets us regain precision and confidence about the behavior of our purely functional effects.

All 19 comments

Why leftMao the Either va just return F[Either[Throwable, A]] and let people leftMap the Either as needed.

Also, I don’t really like the way we use Either vs Try in this repo.

@johnynek Sure, I'd argue that's just an implementation detail, we could easily allow two different methods that can be implemented in terms of each other:

trait NoErrorSync[F[_]] extends Monad[F] {
  def delayCatchMap[E, A](thunk: => A)(f: Throwable => E): F[Either[E, A]]
  def delayCatch[A](thunk: => A): F[Either[Throwable, A]] = delayCatchMap(thunk)(identity)
}

These are names are quire bad right now, but we can probably come up with something better :)

Given a NoErrorSync we can use EitherT to make a Sync, no?

If so, and if the only was to get into F is via EitherT anyway (given this signature) it is not clear to me what we are gaining here.

We gain the ability to separate the error handling from the effect type. We can into F by pure and also by folding the Either, I see what you mean though, so it might make sense to add a def veryUnsafeDelay[A](thunk: => A): F[A], for people that absolutely know what they're doing.

One example of a use case would be fs2.Ref which could benefit from this and as I mentioned before most FP codebases already use some form of EitherT[IO, DomainError, A], which has two MonadError instances, one that's well typed and another that's not.

I am not going to comment on this specific interface, because I think the goal to have a way to represent unexceptional effects can be separated from a discussion of exactly how to achieve that, but I will comment on the extreme utility of what this proposal is trying to achieve.

There is no difference between the type. As currently stands, the type F[A] under the effect hierarchy means that F[_] can fail with Throwable. There is absolutely no way to represent the notion of a value that cannot fail, or can fail with something other than Throwable. Yes, one can represent F[Either[Throwable, A]] in an attempt to represent a value that cannot fail, but this is meaningless, as even calling attempt on an F[_] provides zero type guarantees that the resulting value will not immediately fail with some other Throwable.

As a consequence of these severe limitations, it becomes impossible to represent the _requirement_ that an effectful computation must _not_ fail for any reason (or, more broadly, must fail for one of N different, strongly-typed reasons under the control of the user).

This problem—which could be called the problem of _dynamic error typing_—is so severe that it has led to numerous bugs in large-scale FP applications I have worked on.

For example, at one point, we have request handlers that must return JSON to the front-end. The goal is that each JSON response is formatted in a specific way that can be interpreted by the front-end. Errors have a specific encoding that the front-end understands and can handle internally or present to the user in an appropriate way. For this reason, we have something like IO[Either[ClientError, Response]] which represents the valid response or the typed error that can eventually be encoded into JSON. However, we have historically had no way to signal that a computation IO[Either[ClientError, Response]] cannot fail. As a result, while programmers have made many attempts to handle all errors and convert them into nice typed error values, sometimes they forget or some unexpected error sneaks in, and we end up with a value of type IO[Either[ClientError, Response]] that fails for some Throwable. We, or rather our users, discover these "hidden errors" randomly as they explore combinations of features and trigger edge cases.

If we had a way to represent an unexceptional effect, then we could have a single interface with the HTTP layer that would require the type to be Unexceptional[Either[ClientError, Response]]. Because the only way to construct one of these would be to handle all Throwable, then we would have a compile-time guarantee that no more Throwable can sneak in and pollute the REST API.

This is one of many cases where the lack of precise typing creates runtime bugs. We are statically-typed functional programmers for a reason—we like to use the compiler to precisely tell us _if_ and _how_ a computation may fail. The current type class hierarchy does not permit us such precision, because there are no design points in the landscape where the _if_ and _how_ of failure can be specified. As a result, the current dynamic error typing is a regression against the principles that have all driven us to statically-typed FP, and should be addressed in a sane way that lets us regain precision and confidence about the behavior of our purely functional effects.

I agree about considering an unexceptional effect hierarchy, however the problem from my point of view ...

This needs to duplicate the entire hierarchy, so it is a significant effort that I don't see happening until 1.0.0 — if we can add that later in a backwards compatible way, great, otherwise it would have to wait for 2.0.0 IMO ... which isn't absolutely terrible — we could synchronize it with Cats 2.0, which might happen due to Scala.js 1.0.

The other problem is that such a change requires design chops that I personally don't possess, so I cannot comment on its utility.

You know my philosophy, I don't necessarily agree entirely with eliminating exceptions, because we're on running on top of a hostile environment that can always throw due to partial functions and other exceptional situations — and choosing to crash the process for example hasn't been a good strategy for the projects I worked on thus far, but maybe I'm very biased about the environment I worked in. Or you can choose to log the error, but then you end up with non-termination, which can lead to leaked sockets and stuff.

Anyway, the argument for typing all things sounds compelling and I'm entertaining the possibility of an unexceptional type hierarchy. I mean, the user is a grownup and can choose to work with unexceptional effects, I have no problem with it if the argument is strong enough.

The problem that I have is that I cannot provide much useful input in such a design and we need help.

I really liked @jdegoes's perspective above.

I would very much like @djspiewak to give some input 🙏

I am just some guy, but I have been trying to write scala code for a while.

I personally don't throw exceptions unless some code should truly be unreachable (which I would ideally have a way to convince scalac of, but was unable to).

Never-the-less, I recently got burned in production by forgetting to put one interaction with kafka inside of catch-like operation when I was working with an Either-style error. Of course, this was my mistake. Every call to the library I should have double checked if it would throw, or assume the worst.

I think giving people code to pretend jvm libraries don't throw exceptions, is asking for more of these problems, especially with novices. Sadly, even some scala libraries throw to signal errors. I would love to be working in a language without throw and perhaps only panic similar to rust, but given we aren't, I'm afraid of adding more footguns.

Let me preface this by saying that I fully agree with you @johnynek, however I do want to point out
that when you're using IO right now, you can just as easily shoot yourself in the foot. When I don't handle errors with handleError or attempt, I'm never guaranteed that anything will actually run. I've been burned in production as well, where I didn't handle an error in some place and now my app no longer works. I told myself that this IO was unlikely to fail and later forgot about it. If you only wrap a bunch of imperative exception throwing stuff in IO, you'll likely get burned if you don't handle errors carefully, this is true today.

We always need to be careful when adding these security-hatches, it's why we call it unsafeRunSync among others and it's also why I've proposed to call it veryUnsafeDelay a bit further up. In reality, most FFI code will probably asynchronous and the vast majority of async interaction can throw exceptions, so usage of these particular safety hatches would be enough to warrant such a name. I do think there is value in having things like AtomicReference or LocalDate.now represent the fact that they can never fail.

Even if we don't add the veryUnsafeDelay function, we still have pure values lifted into an effect, as well as handled errors. From a types perspective, F.pure(42): F[Int] and F.delay(throw ex): F[Int] look exactly the same. Contrast this with F.pure(42): F[Int] and F.delayCatch(throw ex): F[Either[Throwable, Int]] and if we handle the error on that type we go back to only F:

F.delayCatch[Int](throw ex).map(_.fold): F[Int]

I don't think the utility of Unexceptionality depends on the having veryUnsafeDelay, it would just add some extra benefits for things we KNOW can't throw exceptions. :)

That said, I'd also like to hear more input from others /cc @SystemFw @djspiewak @ChristopherDavenport @rossabaker @mpilquist

Regarding backwards compatibility I think we could add these in a totally source compatible way. It might make sense for the new type classes to be super classes of the current ones, which would probably mean binary incompatibility (I'm not a hundred percent sure on this), but maybe it makes sense to have separate hierarchies altogether. If you folks are okay with it, I can do some experimenting on the weekend :)

I do think there is value in having things like AtomicReference or LocalDate.now represent the fact that they can never fail. The same goes for pure values lifted into an effect. From a types perspective, F.pure(42) and F.delay(throw ex) look exactly the same.

👏 💯

There are infinitely many effects that cannot possibly fail—knowing this at compile-time enables us to better reason about our code, without running it, which is the very reason most of us were drawn to FP to begin with. Similarly, there are effects that may fail in only one of n defined ways, and knowing these ways—precisely and at compile-time—has similar transformative effects on reasoning.

The capability to precisely describe error types does not imply a mandate. In the case of Unexceptional, one must opt-into the machinery and type class(es). In a similar fashion, Scalaz 8 IO's bifunctor design can be utilized as an ordinary IO[Throwable, A], for those who prefer not to precisely model whether and how effects may fail.

Opt-in designs like these allow people who fear Throwables at every turn — even where they demonstrably cannot occur, such as new AtomicReference[Int](0) or System.nanoTime() or numerous other constructors and methods—to allow for the possibility of a boogeyman at every import, or even after attempting and comprehensively handling every Throwable.

On the other hand, they also provide the possibility to describe effects whose errors have been handled, constrained, or mapped to typed ADTs, as per the user's desire.

Stated more concisely, an "unexceptional" type and type class does not constrain people in any way from doing exactly what they are doing today, in the same way they are doing it—it merely liberates people to choose a more suitable tool for some jobs.

If we parameterized the error type on all these types, we could use Throwable for the exceptional case, but Nothing for the no-error case.

I guess, any Monad can be a MonadError with a Nothing error type.

Yes, that's correct, and it's the Scalaz 8 IO design—although Nothing doesn't quite work because scalac will complain about dead code in some cases, but an opaque type synonym for Nothing works well (Void in FP parlance).

screenshot_945

I have a feeling that MonadError in Haskell / Scalaz 7 / Cats is a wrong tool for principled error handling.

Maybe we need a typeclass for (*, *) -> *:

trait MonadErrors[F[_, _]] extends Bifunctor[F]

Which does not "fix" a error to a concrete type E. We could then have methods with contracts defined by types as opposed to laws:

def attempt[E, A](f: F[E, A]): F[Void, Either[E, A]] // Errors shifted to Either
def handleError[E, A](f: E => A): F[Void, A] // No errors remain
def handleErrorWith[E, E2, A](fa: F[E, A])(f: E => F[E2, A]): F[E2, A] // Error type changed
def pure[A](x: A): F[Void, A] // Nothing is thrown
def catchNonFatal[A](thunk: => A): F[Throwable, A]

@oleg-py I'm writing a blog post on exactly this. :smile:

@oleg-py Here's the Scalaz 8 type class:

trait FallibleClass[B[_, _]] extends BifunctorClass[B] {
  def monadClass[E]: MonadClass[B[E, ?]]

  def attempt[E1, E2, A](b: B[E1, A]): B[E2, E1 \/ A]

  def fail[E, A](e: E): B[E, A]
}

It has E introduction and elimination in the smallest surface area possible that still permits laws (actually you only need to guarantee Applicative).

Here's the blog post I mentioned earlier :)
https://typelevel.org/blog/2018/04/13/rethinking-monaderror.html

@LukaJCB nice article. I didn’t look at the code yet, but I assume lowering this to ApplicativeError is straightforward. Are there are issues with that?

Nope there should be zero issues with that, just need to figure out new laws, I've checked locally for Validated and it seemed fine :)

This starts to get into cats-effect 3 territory. I'll reserve discussion for #321.

Was this page helpful?
0 / 5 - 0 ratings