Skip to content

Commit 4a36eec

Browse files
authored
Only catch nonfatal exceptions using catching (#347)
1 parent 9020c99 commit 4a36eec

File tree

5 files changed

+61
-34
lines changed

5 files changed

+61
-34
lines changed

core/src/main/scala/ox/either.scala

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,6 @@ import scala.reflect.ClassTag
99

1010
object either:
1111

12-
/** Catches non-fatal exceptions that occur when evaluating `t` and returns them as the left side of the returned `Either`. */
13-
inline def catchingNonFatal[T](inline t: Label[Either[Throwable, T]] ?=> T): Either[Throwable, T] =
14-
try boundary(Right(t))
15-
catch case NonFatal(e) => Left(e)
16-
1712
private type NotNested = NotGiven[Label[Either[Nothing, Nothing]]]
1813

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

46+
/** Same as an `either` block created using [[apply]], but additionally catches all non-fatal exceptions that occur when evaluating `t`.
47+
* The error type is fixed to `Throwable`, and all caught exceptions are represented as the left side of the returned `Either`.
48+
*/
49+
inline def catchAll[T](inline t: Label[Either[Throwable, T]] ?=> T): Either[Throwable, T] =
50+
try boundary(Right(t))
51+
catch case NonFatal(e) => Left(e)
52+
5153
extension [E, A](inline t: Either[E, A])
5254
/** Unwrap the value of the `Either`, short-circuiting the computation to the enclosing [[either]], in case this is a left-value. */
5355
transparent inline def ok(): A =
@@ -146,8 +148,10 @@ object either:
146148
case Left(throwable) => throw throwable
147149

148150
extension [T](inline t: T)
149-
/** Catches `E` exceptions that occur when evaluating `t` and returns them as the left side of the returned `Either`. */
151+
/** Catches `E` exceptions that occur when evaluating `t` and returns them as the left side of the returned `Either`. Only non-fatal
152+
* exceptions are caught, even if they are a subtype of `E`.
153+
*/
150154
inline def catching[E <: Throwable: ClassTag]: Either[E, T] =
151155
try Right(t)
152-
catch case e: E => Left(e)
156+
catch case NonFatal(e: E) => Left(e)
153157
end either

core/src/test/scala/ox/EitherTest.scala

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,23 +120,23 @@ class EitherTest extends AnyFlatSpec with Matchers:
120120
}
121121

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

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

129129
e shouldBe a[InterruptedException]
130130
}
131131

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

135-
either.catchingNonFatal(val1.ok()) shouldBe Left(ComparableException("oh no"))
135+
either.catchAll(val1.ok()) shouldBe Left(ComparableException("oh no"))
136136
}
137137

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

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

195+
it should "not catch fatal exceptions" in {
196+
val e = new InterruptedException("boom")
197+
intercept[InterruptedException]((throw e).catching[Exception]) shouldBe e
198+
}
199+
195200
it should "return successful results as Right-values" in {
196201
10.catching[Exception] shouldBe Right(10)
197202
}

cursor-rules/111-ox-dual-error-handling.mdc

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
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.
2+
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.
33
globs:
44
alwaysApply: false
55
---
@@ -44,13 +44,18 @@ val result: Either[Int | Long, String] = either:
4444

4545
## Converting Exceptions to Either
4646

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

4949
```scala
50-
val result: Either[Throwable, String] = either.catching:
51-
riskyOperation() // catches non-fatal exceptions
50+
import either.catching
51+
52+
val result: Either[IllegalStateException, String] =
53+
riskyOperation().catching[IllegalStateException]
5254
```
5355

56+
This helps to pinpoint where exceptions might originate, and either completely eliminate `try-catch` blocks, or
57+
to keep them as small as possible.
58+
5459
## Important: Avoid Nested `either` Blocks
5560

5661
**Don't nest `either` blocks** in the same scope - this can cause surprising behavior after refactoring:

cursor-rules/generation/rules-list.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Overview of Ox library for direct-style Scala 3 programming covering streaming,
77
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.
88

99
## ox-dual-error-handling
10-
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.
10+
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.
1111

1212
## ox-streaming-flows-over-channels
1313
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.

doc/basics/error-handling.md

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,10 @@ val result: Either[String, Int] = either:
127127
if v1.ok() > 10 then 42 else "wrong".fail()
128128
```
129129

130-
An exception-throwing expression can be converted to an `Either` using the `.catching[E]` extension method:
130+
### Converting from exceptions
131+
132+
An exception-throwing expression can be converted to an `Either` using the `.catching[E]` extension method (catches
133+
only non-fatal exceptions!):
131134

132135
```scala mdoc:compile-only
133136
import ox.either.catching
@@ -138,12 +141,37 @@ val result: Either[IllegalArgumentException, Int] =
138141
.catching[IllegalArgumentException]
139142
```
140143

141-
Arbitrary exception-throwing code can be converted to an `Either` using `catchingNonFatal`:
144+
Any `try-catch` blocks that you have in your code should be kept as small as possible, so that it's possibly obvious
145+
where the errors might originate. Using `.catching` at the sites where exceptions are thrown helps keeps the syntax
146+
lean and enables pinpointing where exceptions might occur.
147+
148+
An alternative to an `either` block is an `either.catchAll` block which additionally catches any non-fatal exceptions
149+
that occur when evaluating the nested expression. Within the block, both `.ok()` and `.fail()` can be used. The error
150+
type within such block is fixed to `Throwable`:
142151

143152
```scala mdoc:compile-only
144153
import ox.either
154+
import ox.either.ok
155+
156+
def doWork(): Either[Exception, Boolean] = ???
157+
158+
val result: Either[Throwable, String] = either.catchAll:
159+
if doWork().ok() then "ok" else throw new RuntimeException("not ok")
160+
```
161+
162+
## Converting to exceptions
163+
164+
For `Either` instances where the left-side is an exception, the right-value of an `Either` can be unwrapped using `.orThrow`.
165+
The exception on the left side is thrown if it is present:
166+
167+
```scala mdoc:compile-only
168+
import ox.either.orThrow
169+
170+
val v1: Either[Exception, Int] = Right(10)
171+
assert(v1.orThrow == 10)
145172

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

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

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

206-
## Other `Either` utilities
207-
208-
For `Either` instances where the left-side is an exception, the right-value of an `Either` can be unwrapped using `.orThrow`.
209-
The exception on the left side is thrown if it is present:
210-
211-
```scala mdoc:compile-only
212-
import ox.either.orThrow
213-
214-
val v1: Either[Exception, Int] = Right(10)
215-
assert(v1.orThrow == 10)
216-
217-
val v2: Either[Exception, Int] = Left(new RuntimeException("boom!"))
218-
v2.orThrow // throws RuntimeException("boom!")
219-
```
220-
221234
## Mapping errors
222235

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

0 commit comments

Comments
 (0)