Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions core/src/main/scala/ox/either.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,6 @@ import scala.reflect.ClassTag

object either:

/** Catches non-fatal exceptions that occur when evaluating `t` and returns them as the left side of the returned `Either`. */
inline def catchingNonFatal[T](inline t: Label[Either[Throwable, T]] ?=> T): Either[Throwable, T] =
try boundary(Right(t))
catch case NonFatal(e) => Left(e)

private type NotNested = NotGiven[Label[Either[Nothing, Nothing]]]

/** Within an [[either]] block, allows unwrapping [[Either]] and [[Option]] values using [[ok()]]. The result is the right-value of an
Expand Down Expand Up @@ -48,6 +43,13 @@ object either:
) nn: NotNested
): Either[E, A] = boundary(Right(body))

/** Same as an `either` block created using [[apply]], but additionally catches all non-fatal exceptions that occur when evaluating `t`.
* The error type is fixed to `Throwable`, and all caught exceptions are represented as the left side of the returned `Either`.
*/
inline def catchAll[T](inline t: Label[Either[Throwable, T]] ?=> T): Either[Throwable, T] =
try boundary(Right(t))
catch case NonFatal(e) => Left(e)

extension [E, A](inline t: Either[E, A])
/** Unwrap the value of the `Either`, short-circuiting the computation to the enclosing [[either]], in case this is a left-value. */
transparent inline def ok(): A =
Expand Down Expand Up @@ -146,8 +148,10 @@ object either:
case Left(throwable) => throw throwable

extension [T](inline t: T)
/** Catches `E` exceptions that occur when evaluating `t` and returns them as the left side of the returned `Either`. */
/** Catches `E` exceptions that occur when evaluating `t` and returns them as the left side of the returned `Either`. Only non-fatal
* exceptions are caught, even if they are a subtype of `E`.
*/
inline def catching[E <: Throwable: ClassTag]: Either[E, T] =
try Right(t)
catch case e: E => Left(e)
catch case NonFatal(e: E) => Left(e)
end either
13 changes: 9 additions & 4 deletions core/src/test/scala/ox/EitherTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -120,23 +120,23 @@ class EitherTest extends AnyFlatSpec with Matchers:
}

it should "catch non fatal exceptions" in {
either.catchingNonFatal(throw new RuntimeException("boom")).left.map(_.getMessage) shouldBe Left("boom")
either.catchAll(throw new RuntimeException("boom")).left.map(_.getMessage) shouldBe Left("boom")
}

it should "not catch fatal exceptions" in {
val e = intercept[InterruptedException](either.catchingNonFatal(throw new InterruptedException()))
val e = intercept[InterruptedException](either.catchAll(throw new InterruptedException()))

e shouldBe a[InterruptedException]
}

it should "provide an either scope when catching non fatal exceptions" in {
val val1: Either[Throwable, Int] = Left(ComparableException("oh no"))

either.catchingNonFatal(val1.ok()) shouldBe Left(ComparableException("oh no"))
either.catchAll(val1.ok()) shouldBe Left(ComparableException("oh no"))
}

it should "report a proper compilation error when wrong error type is used for ok() in catchingNonFatal block" in {
val e = intercept[TestFailedException](assertCompiles("""either.catchingNonFatal(fail1.ok())"""))
val e = intercept[TestFailedException](assertCompiles("""either.catchAll(fail1.ok())"""))

e.getMessage should include("The enclosing `either` call uses a different error type.")
}
Expand Down Expand Up @@ -192,6 +192,11 @@ class EitherTest extends AnyFlatSpec with Matchers:
intercept[IllegalArgumentException]((throw e).catching[IllegalStateException]) shouldBe e
}

it should "not catch fatal exceptions" in {
val e = new InterruptedException("boom")
intercept[InterruptedException]((throw e).catching[Exception]) shouldBe e
}

it should "return successful results as Right-values" in {
10.catching[Exception] shouldBe Right(10)
}
Expand Down
13 changes: 9 additions & 4 deletions cursor-rules/111-ox-dual-error-handling.mdc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
description: Use exceptions for bugs/unexpected situations and use `Either` values for application logic errors. Use Rust-like `either` blocks with `.ok()` to unwrap `Either` values with automatic short-circuiting. Combine different error types using union types (`Either[Int | Long, String]`) and avoid nested `either` blocks in the same scope.
description: Use exceptions for bugs/unexpected situations and use `Either` values for application logic errors. Use Rust-like `either` blocks with `.ok()` to unwrap `Either` values with automatic short-circuiting. Combine different error types using union types (`Either[Int | Long, String]`), keep try-catch blocks small and avoid nested `either` blocks in the same scope.
globs:
alwaysApply: false
---
Expand Down Expand Up @@ -44,13 +44,18 @@ val result: Either[Int | Long, String] = either:

## Converting Exceptions to Either

**Convert exception-throwing code** using `either.catching`:
**Convert exception-throwing code** using the inline `catching` (only non-fatal exceptions):

```scala
val result: Either[Throwable, String] = either.catching:
riskyOperation() // catches non-fatal exceptions
import either.catching

val result: Either[IllegalStateException, String] =
riskyOperation().catching[IllegalStateException]
```

This helps to pinpoint where exceptions might originate, and either completely eliminate `try-catch` blocks, or
to keep them as small as possible.

## Important: Avoid Nested `either` Blocks

**Don't nest `either` blocks** in the same scope - this can cause surprising behavior after refactoring:
Expand Down
2 changes: 1 addition & 1 deletion cursor-rules/generation/rules-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Overview of Ox library for direct-style Scala 3 programming covering streaming,
Prefer high-level operations like `par()`, `race()`, and `timeout()` for concurrent programming instead of manual fork management. These operations handle error propagation, interruption, and resource cleanup automatically. Use `.mapPar`, `.filterPar` and `.collectPar` on large collections for added performance.

## ox-dual-error-handling
Use exceptions for bugs/unexpected situations and use `Either` values for application logic errors. Use Rust-like `either` blocks with `.ok()` to unwrap `Either` values with automatic short-circuiting. Combine different error types using union types (`Either[Int | Long, String]`) and avoid nested `either` blocks in the same scope.
Use exceptions for bugs/unexpected situations and use `Either` values for application logic errors. Use Rust-like `either` blocks with `.ok()` to unwrap `Either` values with automatic short-circuiting. Combine different error types using union types (`Either[Int | Long, String]`), keep try-catch blocks small and avoid nested `either` blocks in the same scope.

## ox-streaming-flows-over-channels
Use Flows for functional-style streaming transformations with methods like `map`, `mapPar`, `filter`, `groupBy` and many others. Flows are lazy, composable, and manage concurrency declaratively and efficiently. Flows only start processing data when run.
Expand Down
49 changes: 31 additions & 18 deletions doc/basics/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@ val result: Either[String, Int] = either:
if v1.ok() > 10 then 42 else "wrong".fail()
```

An exception-throwing expression can be converted to an `Either` using the `.catching[E]` extension method:
### Converting from exceptions

An exception-throwing expression can be converted to an `Either` using the `.catching[E]` extension method (catches
only non-fatal exceptions!):

```scala mdoc:compile-only
import ox.either.catching
Expand All @@ -138,12 +141,37 @@ val result: Either[IllegalArgumentException, Int] =
.catching[IllegalArgumentException]
```

Arbitrary exception-throwing code can be converted to an `Either` using `catchingNonFatal`:
Any `try-catch` blocks that you have in your code should be kept as small as possible, so that it's possibly obvious
where the errors might originate. Using `.catching` at the sites where exceptions are thrown helps keeps the syntax
lean and enables pinpointing where exceptions might occur.

An alternative to an `either` block is an `either.catchAll` block which additionally catches any non-fatal exceptions
that occur when evaluating the nested expression. Within the block, both `.ok()` and `.fail()` can be used. The error
type within such block is fixed to `Throwable`:

```scala mdoc:compile-only
import ox.either
import ox.either.ok

def doWork(): Either[Exception, Boolean] = ???

val result: Either[Throwable, String] = either.catchAll:
if doWork().ok() then "ok" else throw new RuntimeException("not ok")
```

## Converting to exceptions

For `Either` instances where the left-side is an exception, the right-value of an `Either` can be unwrapped using `.orThrow`.
The exception on the left side is thrown if it is present:

```scala mdoc:compile-only
import ox.either.orThrow

val v1: Either[Exception, Int] = Right(10)
assert(v1.orThrow == 10)

val result: Either[Throwable, String] = either.catchingNonFatal(throw new RuntimeException("boom"))
val v2: Either[Exception, Int] = Left(new RuntimeException("boom!"))
v2.orThrow // throws RuntimeException("boom!")
```

### Nested `either` blocks
Expand Down Expand Up @@ -203,21 +231,6 @@ val outerResult: Either[Exception, Unit] = either:

After this change refactoring `returnsEither` to return `Either[Exception, Int]` would yield a compile error on `returnsEither.ok()`.

## Other `Either` utilities

For `Either` instances where the left-side is an exception, the right-value of an `Either` can be unwrapped using `.orThrow`.
The exception on the left side is thrown if it is present:

```scala mdoc:compile-only
import ox.either.orThrow

val v1: Either[Exception, Int] = Right(10)
assert(v1.orThrow == 10)

val v2: Either[Exception, Int] = Left(new RuntimeException("boom!"))
v2.orThrow // throws RuntimeException("boom!")
```

## Mapping errors

When an `Either` is used to represent errors, these can be mapped by using `.left.map`. This can be used to entirely
Expand Down