St.Antario
St.Antario

Reputation: 27375

Error conversion in Scala in a pure and safe way

I'm using cats-effect to suspend side effects and came across difficulties when implementing pure functions avoiding error-prone Throwables. 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 Throwables with try-catch. Can anyone suggest a better/safer technique for dealing with errors?

Upvotes: 2

Views: 519

Answers (1)

TheInnerLight
TheInnerLight

Reputation: 12184

When you're in the mirky world of IO, there is no getting away from the fact that Throwables 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

Related Questions