Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: either.catching should be a boundary operator #150

Closed
lbialy opened this issue Jun 5, 2024 · 10 comments
Closed

Proposal: either.catching should be a boundary operator #150

lbialy opened this issue Jun 5, 2024 · 10 comments

Comments

@lbialy
Copy link
Contributor

lbialy commented Jun 5, 2024

One of the least pleasant surprises when working with direct style scala using Ox is that we're back to exception throwing and lying signatures. I found out the hard way that one of the nicest things one gets from monadic effects is that they capture exceptions pervasively so in 99% of cases if a function gets you an IO[A], you will get an IO[A] and not an exception. This is not true with ox either combinators as either does not naturally capture (and hence why maybe the idea to have Result[A] that has a Throwable error channel is not that bad idea? in a different discussion we could debate if Try catching on all combinators is still bad, given that IO monads also do that? maybe Ox should have combinators for Try?). Either way (pun not intended), given that either {} block does not catch exceptions but returns an Either[E, A] there seems to be no nice, useful way to work with both structured either and exceptions in one go. Let me visualize this:

import ox.*, either.*
import scala.util.*
import java.io.IOException

def niceSafeFunc(): Either[IOException, String] = Right("something") 
def thisFuncCanThrow(str: String): String = throw Exception("lying signature")

def safeFunc: Either[Exception, Int] = either {
  val first = niceSafeFunc().ok()
  // do things
  val output = thisFuncCanThrow(first)
  // do stuff
  output.toInt // this can also throw and one can easily forget about it when used to IO monads always catching
}

this obviously crashes:

scala> safeFunc
java.lang.Exception: lying signature
  at rs$line$2$.thisFuncCanThrow(rs$line$2:5)
  at rs$line$2$.safeFunc(rs$line$2:10)
  ... 30 elided

because exceptions subvert the type of safeFunc. With current state of ox when one is facing such unsafe APIs one can only do:

import ox.*, either.*
import scala.util.*
import java.io.IOException

def niceSafeFunc(): Either[IOException, String] = Right("something") 
def thisFuncCanThrow(str: String): String = throw Exception("lying signature")

def safeFunc: Either[Throwable, Int] = catching {
  either {
    val first = niceSafeFunc().ok()
    // do things
    val output = thisFuncCanThrow(first)
    // do stuff
    output.toInt // this can also throw and one can easily forget about it when used to IO monads always catching
  }
}.flatten

which does the job at the cost of changing the return type of safeFunc to Either[Throwable, Int] but it's also quite redundant - I want to use nice boundary-based combinators, I want to be able to use .ok() but to do that I have to have a nested either scope. Proposal is to turn catching into a boundary operator too:

from this:

inline def catching[T](inline t: => T): Either[Throwable, T] =
  try Right(t)
  catch case NonFatal(e) => Left(e)

to this:

inline def catching[T](inline t: Label[Either[Throwable, T]] ?=> T): Either[Throwable, T] =
 try boundary(Right(t))
 catch case NonFatal(e) => Left(e)

so that in the end we get:

import ox.*, either.*
import scala.util.*
import java.io.IOException

def niceSafeFunc(): Either[IOException, String] = Right("something") 
def thisFuncCanThrow(str: String): String = throw Exception("lying signature")

def safeFunc: Either[Throwable, Int] = either.catching {
  val first = niceSafeFunc().ok()
  // do things
  val output = thisFuncCanThrow(first)
  // do stuff
  output.toInt // this can also throw and one can easily forget about it when used to IO monads always catching
}

Your thoughts?

@lbialy
Copy link
Contributor Author

lbialy commented Jun 5, 2024

Alternative is to have and use two control structures, as stdlib has it right now - Try and Either, a functor and bifunctor, and allow users to use whatever fits their needs the most? Given their differences in implementation, maybe a Result that doesn't catch on combinators but is just an either with Throwable channel instead of generic Left would fit better than Try? I actually doubt that - monadic effects went from "your monad is shitty if it captures exceptions" to "yeah, we capture exceptions on all IO combinators" and this seems to be the correct, unsurprising way of doing things in the end.

@lbialy
Copy link
Contributor Author

lbialy commented Jun 5, 2024

This is also an option:

  import scala.util.{Try, Failure, Success}
  
  extension [A](inline t: Try[A])
    transparent inline def ok(): A =
      summonFrom {
        case given boundary.Label[Either[Throwable, Nothing]] =>
          t match
            case Failure(t) => break(Left(t))
            case Success(a) => a
        case given boundary.Label[Either[Nothing, Nothing]] =>
          error(
            "The enclosing `either` call uses a different error type.\nIf it's explicitly typed, is the error type correct? For `Try` error type has to be `Throwable`."
          )
        case _ => error("`.ok()` can only be used within an `either` call.\nIs it present?")
      }
   import ox.*, either.*
   import scala.util.*
   import java.io.IOException
   case class CaseEx(msg: String) extends Exception(msg)

   def niceSafeFunc(): Try[String] = Try("something")

   def thisFuncCanThrow(str: String): String = throw CaseEx("lying signature")

   def safeFunc: Either[Throwable, Int] = either[Throwable, Int] {
     val first = niceSafeFunc().ok()
     // do things
     val output: String = Try(thisFuncCanThrow(first)).ok()
     // do stuff
     output.toInt // this can also throw and one can easily forget about it when used to IO monads always catching
   }

   safeFunc shouldEqual Left(CaseEx("lying signature"))

@adamw
Copy link
Member

adamw commented Jun 12, 2024

I found out the hard way that one of the nicest things one gets from monadic effects is that they capture exceptions pervasively so in 99% of cases if a function gets you an IO[A], you will get an IO[A] and not an exception.

Yes, but what happens when you run the IO[A], at the edge of your program? If the exception is unhandled - that is, if there's not .recover, or in direct's equivalent try-catch, you'll get an exception. Same in Ox - if you've got an unhandled exception, it's going to get thrown.

In cats-effect, what does an IO[A] signature tell you - or how is it "truthful"? You know that: (a) some async calls are probably happening (I/O or concurrency operators), (b) evaluating it might (but doesn't have to) produce exceptions - as they might be handled. ZIO tells you a bit more, as it specifies if "expected" errors are handled or not. I'm eliding over the fact that a method returning an IO[A] shouldn't throw exceptions, as it itself constructs a program description, a stage which we don't have in Ox by design.

What can we do in Ox?

(a) always go with returning Eithers, trying to enforce it for each I/O operation
(b) introduce Result, which would be a "better Try"
(c) (my choice so far, not necessarily the best) optionally use Eithers for type-safe errors, use IO capability to signal that I/O might happen in a method
(d) ?

@lbialy
Copy link
Contributor Author

lbialy commented Jun 12, 2024

Haha, before we get into a very deep discussion about error channels, modeling of error channels (only exceptions are built-in, Try or Either require special handling and syntax) and whether exceptions should be used (I'm firmly in the no, they shouldn't team btw), let's get this out of the way:

Proposal is to turn catching into a boundary operator too:

from this:

inline def catching[T](inline t: => T): Either[Throwable, T] =
  try Right(t)
  catch case NonFatal(e) => Left(e)

to this:

inline def catching[T](inline t: Label[Either[Throwable, T]] ?=> T): Either[Throwable, T] =
 try boundary(Right(t))
 catch case NonFatal(e) => Left(e)

@adamw
Copy link
Member

adamw commented Jun 14, 2024

Exceptions are a fact of life on the JVM, I think the best you can do is try to push them to the role of "panics". But back to the main topic: I suppose, but then it would make sense to move it to object either. So that it's next to apply (the main boundary method), but it also catches exceptions & is specialised to throwables

@lbialy
Copy link
Contributor Author

lbialy commented Jun 14, 2024

Sure, in Scala code - yeah, treating them as panics is probably mostly fine. Issue is: they are very often used for handling of things that should not be panics. A common example is, of course, "not a number".toInt which throws NumberFormatException because why not. If you have a piece of foreign code that does indeed do this and you want to handle that gracefully you have to catch and that's where catching is very helpful. It would just remove some boilerplate if it was a boundary for Label[Either[Throwable, A]].

@adamw
Copy link
Member

adamw commented Jun 15, 2024

using .toInt instead of .toIntOption when you're not 100% sure that it's a number is a bug - especially when you're not catching the exception. But sure, let's add the label, and move it to object either

@lbialy
Copy link
Contributor Author

lbialy commented Jun 15, 2024 via email

@lbialy
Copy link
Contributor Author

lbialy commented Jun 29, 2024

#167

@lbialy
Copy link
Contributor Author

lbialy commented Jul 1, 2024

implemented with #167

@lbialy lbialy closed this as completed Jul 1, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants