IOHandle is a small library that provides ergonomic type-safe error handling for cats-effect IO.
It is based on cats-mtl's Handle and Raise capabilities,
but specialized for cats.effect.IO
with some additional helpers and user-friendliness.
libraryDepedencies ++= Seq("com.github.jatcwang" %% "iohandle" % "<VERSION>")
- Call
ioHandling[E]
and start an error-handling scope for the provided error (E
) - Anywhere within the scope, you can call
ioAbort(e: E)
to abort the execution - Wrap it all up by specifying how the typed error is finally handled. You can..
- Transform/Process the error using
.rescue
/.rescueWith
- Convert the result to an
Either[E, A]
using.toEither
- Extra note: Instead of calling methods like
handleErrorWith
to handle untyped exceptions from an IO, switch to usinghandleUnexpectedWith
. This is becauseioAbort
internally usesIO.raiseError
with a special exception, so these extension methods will help you avoid accidentally interfering with IOHandle's error processing. See How it works section for more details.
If you squint a little bit, it is similar to try-catch
except it works with IO
Scala 3:
val prog: IO[String] =
ioHandling[SomeError]:
for
isSuccess <- checkSomething
_ <- if (isSuccess) ioAbort(SomeError("oops")) else IO.unit
yield "success!"
.rescue:
e => e.message
Let's look an example of handling a user's file upload, where possible errors are FileTooLarge and QuotaExceeded
Scala 3:
import cats.effect.IO
import iohandle.{ioHandling, ioAbort, ioAbortIf}
def uploadFile(userId: UserId, parentPath: Path, file: File): IO[Either[UploadError, String]] =
ioHandling[UploadError]:
for
_ <- if (file.size > MaxPerFileBytes)
ioAbort(FileTooLarge(MaxPerFileBytes, file.size))
else IO.unit
used <- getUsedQuota(userId)
remaining = MaxUserQuotaBytes - used
// ioAbortIf is a equivalent to `if (..) ioAbort(..) else IO.unit`
_ <- ioAbortIf(remaining < file.size, QuotaExceeded(userId, remaining))
url <- saveToStorage(userId, file)
yield url
.toEither
Scala 2:
import cats.effect.IO
import iohandle.*
def uploadFile(userId: UserId, parentPath: Path, file: File): IO[Either[UploadError, String]] =
ioHandling[UploadError] { implicit handle =>
for {
_ <- if (file.size > MaxPerFileBytes)
ioAbort(FileTooLarge(MaxPerFileBytes, file.size))
else IO.unit
used <- getUsedQuota(userId)
remaining = MaxUserQuotaBytes - used
_ <- ioAbortIf(remaining < file.size, QuotaExceeded(userId, remaining))
url <- saveToStorage(userId, file)
}
yield url
}
.toEither
When ioHandling[E]
is called, a "capability" value of type IOHandle
is created with a unique marker.
When ioAbort
is called with your domain error myError
, it wraps your error value in a special exception IOHandleErrorWrapper(myError, marker)
and throws it using IO.raiseError
. When ioHandling
checks for errors, it matches IOHandleErrorWrapper
and compares its marker to
the one it created. If they match, it knows it can extract an error of type E
from the caught IOHandleErrorWrapper
.
If we deconstruct all the code surrounding ioHandling
, below is essentially what it boils down to:
def doStuff(input: Int)(using IORaise[MyError]): IO[Int] = ...
val uniqueMarker = new Object // java.lang.Object are compared by reference
given IORaise[MyError] = new IORaise[MyError] {
def raise(e: MyError): IO[Nothing]
}
doStuff(42)
.handleErrorWith {
case s: IOHandleErrorWrapper[?] if s.marker == marker =>
// Because the marker matched the one we created above, we know the error is of type MyError
val myError = s.error.asInstanceOf[MyError]
// ... do stuff with myError
case e =>
// For any other types of exceptions, or IOHandleErrorWrapper with a different marker,
// re-throw them because they'll be handled by their own handlers
IO.raiseError(e)
}
IORaise[E] allows you to raise an error of type E
.
- It is contravariant, which means if you have a
IORaise[ParentError]
, the sameIORaise
instance can act asIORaise[SubError]
. This is useful for limiting what error each function can raise. - It is a specialization of
cats.mtl.Raise
for the effect typecats.effect.IO
IOHandle[E] capability extends IORaise[E]
, allowing you to intercept and handle error of type E
in addition to just raising them.
- In most cases a function only require the
IORaise[E]
capability, so we recommend doing just that. - It is a specialization of
cats.mtl.Handle
for the effect typecats.effect.IO
- cats-mtl's "Submarine Error Handling"
- This library uses the same mechanism as detailed in the blog post, with some minor API differences and user-friendliness
- ValdemarGr's catch-effect library
- Difference: We rely on
IO.raiseError
instead of IO cancellation
- Difference: We rely on