Reputation: 27375
I'm using cats-effect
to suspend side effects and came across difficulties when implementing pure functions avoiding error-prone Throwable
s. The problem is that cats.effect.Sync[F[_]]
extends Bracket[F, Throwable]
.
sealed trait Err
final case class FileExistError(path: String) extends Err
case object UnknownError extends Err
final case class FileExistThrowable(path: String, cause: Throwable) extends Throwable
final class File[F[_]: Sync]{
def rename(from: String, to: String): F[Unit] =
implicitly[Sync[F]] delay {
try{
Files.move(Paths.get(from), Paths.get(to))
} catch {
case e: FileAlreadyExistsException =>
throw FileExistThrowable(to, e)
case e => throw e
}
}
}
In case e.g. cats.effect.IO
I can convert the effects using NaturalTransform as follows:
implicit val naturalTransform: IO ~> EitherT[IO, Err, ?] =
new ~>[IO, EitherT[IO, Err, ?]] {
override def apply[A](fa: IO[A]): EitherT[IO, Err, A] =
EitherT(
fa.attempt map { e =>
e.left map {
case FileExistsThrowable(path, cause) =>
FileExistsError(path)
case NonFatal(e) =>
UnknownError
}
}
)
}
Unfortunately this seems unreliable and error prone-way. In the effectful implementation we are free to throw any kind of throwable which will be reported as UnknownError
.
This does not seem to be more reliable then simply using Throwable
s with try-catch
. Can anyone suggest a better/safer technique for dealing with errors?
Upvotes: 2
Views: 519
Reputation: 12184
When you're in the mirky world of IO
, there is no getting away from the fact that Throwable
s can happen. The key is to differentiate between errors that are truly exceptional and those which are expected.
It's a never-ending quest to try to build a typed model of the possible errors that can occur in the wild so my advice is not to try. Rather, decide on the errors that you want to reify into the API and allow any others to occur as Throwables
in IO
, that way the caller can decide if and where they want to handle exceptional situations while enforcing handling of expected errors.
A really simple example of your scenario could be:
final case class FileAlreadyExists(path: String)
final class File[F[_]: Sync]{
def rename(from: String, to: String): F[Either[FileAlreadyExists, Unit]] =
Sync[F].delay { Files.move(Paths.get(from), Paths.get(to))}.attempt.flatMap {
case Left(_ : FileAlreadyExistsException) => Sync[F].pure(Left(FileAlreadyExists(to)))
case Left(e) => Sync[F].raiseError(e)
case Right(_) => Sync[F].pure(Right(()))
}
}
This way you differentiate between the expected error of renaming a file to one that already exists (which occurs in Either
and is well typed) and totally unexpected errors (which still occur in IO
) and can potentially be handled elsewhere.
Upvotes: 1