Skip to content

Commit

Permalink
prelude: introduce Abort.recover and Kyo.pure (#753)
Browse files Browse the repository at this point in the history
I took a look at ZIO's error handling as part of
#651 and it's essentially based on
user-defined runtime pattern matching without a mechanism like Kyo's to
safely handle multiple failure types at once via tags. I'm not sure
there's much we can incorporate from ZIO but something that is a major
advantage is how easy it is to recover and transform errors in ZIO
compared to Kyo. Users currently need to use `Abort.run`, which returns
a `Result`, and then perform additional transformations.

This PR introduces `Abort.recover` to directly recover specific
failures. It provides two versions:

1. `Abort.recover[FailureType](onFail)(computation)`: Only handles
`Fail` suspensions and leaves a pending `Abort[Nothing]` in case of
panics.
2. `Abort.recover[FailureType](onFail, onPanic)(computation)`: Handles
both `Fail` and `Panic` suspensions, not leaving an `Abort[Nothing]`
since panics are also handled.

In addition, I had a type inference issue with if/else as recently
elaborated by @steinybot, which prompted the addition of `Kyo.pure`.
  • Loading branch information
fwbrasil authored Oct 17, 2024
1 parent 072d110 commit 3e5682b
Show file tree
Hide file tree
Showing 4 changed files with 337 additions and 0 deletions.
112 changes: 112 additions & 0 deletions kyo-prelude/shared/src/main/scala/kyo/Abort.scala
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ object Abort:
*/
inline def panic[E](inline ex: Throwable)(using inline frame: Frame): Nothing < Abort[E] = error(Panic(ex))

/** Fails the computation with the given error value (failure or panic).
*
* @param e
* The error value to fail with
* @return
* A computation that immediately fails with the given error value
*/
inline def error[E](inline e: Error[E])(using inline frame: Frame): Nothing < Abort[E] =
ArrowEffect.suspendMap[Any](erasedTag[E], e)(_ => ???)

Expand Down Expand Up @@ -200,6 +207,111 @@ object Abort:
*/
inline def run[E]: RunOps[E] = RunOps(())

final class RecoverOps[E](dummy: Unit) extends AnyVal:

/** Recovers from an Abort failure by applying the provided function.
*
* This method allows you to handle failures in an Abort effect and potentially continue the computation with a new value. It only
* handles failures of type E and leaves panics unhandled (Abort[Nothing]).
*
* @param onFail
* A function that takes the failure value of type E and returns a new computation
* @param v
* The original computation that may fail
* @return
* A computation that either succeeds with the original value or the recovered value
*/
def apply[A: Flat, B: Flat, S, ER](onFail: E => B < S)(v: => A < (Abort[E | ER] & S))(
using
frame: Frame,
ct: SafeClassTag[E],
reduce: Reducible[Abort[ER]]
): (A | B) < (S & Abort[ER]) =
ArrowEffect.handle.catching[
Const[Error[E]],
Const[Unit],
Abort[E],
A | B,
A | B,
Abort[ER] & S,
Abort[ER] & S,
Any
](
erasedTag[E],
v
)(
accept = [C] =>
input =>
input.asInstanceOf[Error[Any]].failure.exists(ct.accepts),
handle = [C] =>
(input, _) =>
(input: @unchecked) match
case Fail(e) => onFail(e)
case panic: Panic => Abort.error(panic),
recover =
case ct(fail) if ct <:< SafeClassTag[Throwable] =>
onFail(fail)
case ex =>
Abort.panic(ex)
)

/** Recovers from an Abort failure or panic by applying the provided functions.
*
* This method allows you to handle both failures and panics in an Abort effect. It provides separate handlers for failures of type
* E and for panics (Throwables).
*
* @param onFail
* A function that takes the failure value of type E and returns a new computation
* @param onPanic
* A function that takes a Throwable and returns a new computation
* @param v
* The original computation that may fail or panic
* @return
* A computation that either succeeds with the original value or the recovered value
*/
def apply[A: Flat, B: Flat, S, ER](onFail: E => B < S, onPanic: Throwable => B < S)(v: => A < (Abort[E | ER] & S))(
using
frame: Frame,
ct: SafeClassTag[E],
reduce: Reducible[Abort[ER]]
): (A | B) < (S & reduce.SReduced) =
reduce {
ArrowEffect.handle.catching[
Const[Error[E]],
Const[Unit],
Abort[E],
A | B,
A | B,
Abort[ER] & S,
Abort[ER] & S,
Any
](
erasedTag[E],
v
)(
accept = [C] =>
input =>
input.isPanic ||
input.asInstanceOf[Error[Any]].failure.exists(ct.accepts),
handle = [C] =>
(input, _) =>
(input: @unchecked) match
case Fail(e) => onFail(e)
case Panic(ex) => onPanic(ex),
recover =
case ct(fail) if ct <:< SafeClassTag[Throwable] =>
onFail(fail)
case ex =>
onPanic(ex)
)
}
end apply
end RecoverOps

/** Provides recovery operations for Abort effects.
*/
inline def recover[E]: RecoverOps[E] = RecoverOps(())

final class CatchingOps[E <: Throwable](dummy: Unit) extends AnyVal:
/** Catches exceptions of type E and converts them to Abort failures.
*
Expand Down
16 changes: 16 additions & 0 deletions kyo-prelude/shared/src/main/scala/kyo/Kyo.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ import scala.annotation.tailrec
/** Object containing utility functions for working with Kyo effects. */
object Kyo:

/** Explicitly creates a pure effect that produces the given value.
*
* While pure values are automatically lifted into Kyo computations in most cases, this method can be useful in specific scenarios,
* such as in if/else expressions, to help with type inference.
*
* @tparam A
* The type of the value
* @tparam S
* The effect context (can be Any)
* @param v
* The value to lift into the effect context
* @return
* A computation that produces the given value
*/
inline def pure[A, S](inline v: A): A < S = v

/** Zips two effects into a tuple.
*
* @param v1
Expand Down
190 changes: 190 additions & 0 deletions kyo-prelude/shared/src/test/scala/kyo/AbortTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -797,4 +797,194 @@ class AbortsTest extends Test:
}
}

"Abort.recover" - {
case class CustomError(message: String) derives CanEqual

"without onPanic" - {

"handles expected errors" in {
val computation = Abort.fail(CustomError("Expected error"))
val recovered = Abort.recover[CustomError](_ => 42)(computation)
assert(Abort.run(recovered).eval == Result.success(42))
}

"leaves panics unhandled" in {
val ex = new RuntimeException("Panic!")
val computation = Abort.panic(ex)
val recovered = Abort.recover[CustomError](_ => 42)(computation)
assert(Abort.run(recovered).eval == Result.panic(ex))
}

"doesn't affect successful computations" in {
val computation: Int < Abort[CustomError] = 100
val recovered =
Abort.recover[CustomError](_ => 42)(computation)
assert(Abort.run(recovered).eval == Result.success(100))
}

"keeps Abort in the effect set for panics" in {
val ex = new RuntimeException("Panic!")
val computation = Abort.panic(ex)
val recovered = Abort.recover[CustomError](_ => 42)(computation)
assertDoesNotCompile("val _: Int < Any = recovered")
}
}

"with onPanic" - {

"handles expected errors" in {
val computation = Abort.fail(CustomError("Expected error"))
val recovered = Abort.recover[CustomError](_ => 42, _ => -1)(computation)
assert(recovered.eval == 42)
}

"handles panics" in {
val ex = new RuntimeException("Panic!")
val computation = Abort.panic(ex)
val recovered = Abort.recover[CustomError](_ => 42, _ => -1)(computation)
assert(recovered.eval == -1)
}

"doesn't affect successful computations" in {
val computation: Int < Abort[CustomError] = 100
val recovered = Abort.recover[CustomError](_ => 42, _ => -1)(computation)
assert(recovered.eval == 100)
}

"removes Abort from the effect set" in {
val computation = Abort.fail(CustomError("Expected error"))
val recovered = Abort.recover[CustomError](_ => 42, _ => -1)(computation)
assertCompiles("val _: Int < Any = recovered")
}
}

"interaction with other effects" - {
"works with Env" in {
val computation: Int < (Abort[CustomError] & Env[String]) =
for
env <- Env.get[String]
result <- if env == "fail" then Abort.fail(CustomError("Failed")) else Kyo.pure(env.length)
yield result

val recovered = Abort.recover[CustomError](_ => -1)(computation)
val result = Env.run("success")(Abort.run(recovered))
assert(result.eval == Result.success(7))

val failResult = Env.run("fail")(Abort.run(recovered))
assert(failResult.eval == Result.success(-1))
}

"works with Var" in {
val computation: Int < (Abort[CustomError] & Var[Int]) =
for
value <- Var.get[Int]
result <- if value > 10 then Abort.fail(CustomError("Too large")) else Kyo.pure(value)
yield result

val recovered = Abort.recover[CustomError](_ => -1)(computation)
val result = Var.run(5)(Abort.run(recovered))
assert(result.eval == Result.success(5))

val failResult = Var.run(15)(Abort.run(recovered))
assert(failResult.eval == Result.success(-1))
}
}

"recover function using other effects" - {
case class CustomError(message: String) derives CanEqual

"with Env effect" in {
val computation: Int < Abort[CustomError] = Abort.fail(CustomError("Failed"))
val recovered = Abort.recover[CustomError] { error =>
Env.get[String].map(_.length)
}(computation)

val result = Env.run("TestEnv")(Abort.run(recovered))
assert(result.eval == Result.success(7))
}

"with Var effect" in {
val computation: Int < Abort[CustomError] = Abort.fail(CustomError("Failed"))
val recovered = Abort.recover[CustomError] { error =>
for
current <- Var.get[Int]
_ <- Var.set(current + error.message.length)
result <- Var.get[Int]
yield result
}(computation)

val result = Var.run(10)(Abort.run(recovered))
assert(result.eval == Result.success(16))
}

"with both Env and Var effects" in {
val computation: Int < Abort[CustomError] = Abort.fail(CustomError("Error"))
val recovered = Abort.recover[CustomError] { error =>
for
env <- Env.get[String]
_ <- Var.update[Int](_ + env.length + error.message.length)
result <- Var.get[Int]
yield result
}(computation)

val result = Env.run("TestEnv")(Var.run(5)(Abort.run(recovered)))
assert(result.eval == Result.success(17))
}

"with nested Abort effect" in {
val computation = Abort.fail(CustomError("Outer"))
val recovered = Abort.recover[CustomError](_ => Abort.fail("Inner"))(computation)

assert(Abort.run(recovered).eval == Result.fail("Inner"))
}

"with onPanic using effects" in {
val ex = new RuntimeException("Panic!")
val computation: Int < Abort[CustomError] = Abort.panic(ex)
val recovered = Abort.recover[CustomError](
onFail = _ => Env.get[Int],
onPanic = _ => Var.update[Int](_ + 1).as(Var.get[Int])
)(computation)

val result = Env.run(42)(Var.run(10)(recovered))
assert(result.eval == 11)
}
}

"with pipe operator" - {
case class CustomError(message: String) derives CanEqual

"recovers from failures" in {
val computation: Int < Abort[CustomError] = Abort.fail(CustomError("Failed"))
val result = computation.pipe(Abort.recover[CustomError](_ => 42))
assert(Abort.run(result).eval == Result.success(42))
}

"doesn't affect successful computations" in {
val computation: Int < Abort[CustomError] = 10
val result = computation.pipe(Abort.recover[CustomError](_ => 42))
assert(Abort.run(result).eval == Result.success(10))
}

"can be chained with other operations" in {
val computation: Int < Abort[CustomError] = Abort.fail(CustomError("Failed"))
val result = computation
.pipe(Abort.recover[CustomError](_ => 42))
.map(_ * 2)

assert(Abort.run(result).eval == Result.success(84))
}

"works with onPanic" in {
val ex = new RuntimeException("Panic!")
val computation: Int < Abort[CustomError] = Abort.panic(ex)
val result = computation.pipe(Abort.recover[CustomError](
onFail = _ => 42,
onPanic = _ => -1
))
assert(result.eval == -1)
}
}
}

end AbortsTest
19 changes: 19 additions & 0 deletions kyo-prelude/shared/src/test/scala/kyo/KyoTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -271,4 +271,23 @@ class KyoTest extends Test:
assert(Kyo.foreachIndexed(largeSeq)((idx, v) => idx == v).eval == Chunk.fill(100)(true))
}
}

"pure" - {
"should create a pure effect" in {
val effect = Kyo.pure[Int, Any](42)
assert(effect.eval == 42)
}

"should work with different types" in {
assert(Kyo.pure[String, Any]("hello").eval == "hello")
assert(Kyo.pure[Boolean, Any](true).eval == true)
assert(Kyo.pure[List[Int], Any](List(1, 2, 3)).eval == List(1, 2, 3))
}

"should work with effects" in {
val effect = Kyo.pure[Int < Env[Int], Any](Env.get[Int])
val result = Env.run(10)(effect.flatten)
assert(result.eval == 10)
}
}
end KyoTest

0 comments on commit 3e5682b

Please sign in to comment.