typelevel / cats

Lightweight, modular, and extensible library for functional programming.
https://typelevel.org/cats/
Other
5.23k stars 1.19k forks source link

Ease awkward constructions when doing something before raising #4635

Open morgen-peschke opened 1 month ago

morgen-peschke commented 1 month ago

A common pattern shows up in our code that doesn't seem to have an elegant solution: we'd like to log before raising an error (usually from an Option).

Currently, there are a couple ways to accomplish this for Option values (in rough order of increasing subjective elegance):

fooAlgebra.lookupOpt(id).flatMap {
  case Some(foo) => foo.pure[F]
  case None => log("Relevant context") >> CannotProcess.raiseError[F, Foo]
}

fooAlgebra.lookupOpt(id).flatMap {
  _.fold(log("Relevant context") >> CannotProcess.raiseError[F, Foo])(_.pure[F])
}

fooAlgebra.lookupOpt(id).flatMap {
  _.map(_.pure[F]).getOrElse(log("Relevant context") >> CannotProcess.raiseError[F, Foo])
}

fooAlgebra.lookupOpt(id)
  .flatTap(result => log("Relevant context").whenA(result.isEmpty))
  .flatMap(_.liftTo[F](CannotProcess))

The situation for Either (or Validated) is much better, but still not great:

fooAlgebra.lookupE(id).flatMap {
  case Right(foo) => foo.pure[F]
  case Left(FooNotFound) => log("Relevant context A") >> CannotProcess.raiseError[F, Foo]
  case Left(_) => log("Relevant context B") >> ServerError.raiseError[F, Foo]
}

fooAlgebra.lookupE(id).flatMap(_.fold(
  {
    case FooNotFound => log("Relevant context A") >> CannotProcess.raiseError[F, Foo]
    case _ => log("Relevant context B") >> ServerError.raiseError[F, Foo]
  },
  _.pure[F]
))

fooAlgebra.lookupE(id)
  .flatMap(_.leftTraverse {
    case FooNotFound => log("Relevant context A").as(CannotProcess).widen[LogicError]
    case _ => log("Relevant context B").as(ServerError).widen[LogicError]
  })
  .flatMap(_.liftTo[F])

I propose adding variants of ApplicativeError#from* to MonadError and their equivalent variants of the liftTo helpers in OptionSyntax EitherSyntax and ValidatedSyntax, which would allow writing the above as:

fooAlgebra.lookupOpt(id).flatMap(_.raiseNone {
  log("Relevant context").as(CannotProcess).widen[LogicError]
})

fooAlgebra.lookupE(id)
  .flatMap(_.leftMap[F[LogicError]] {
    case FooNotFound => log("Relevant context A").as(CannotProcess).widen
    case _ => log("Relevant context B").as(ServerError).widen
  })
  .flatMap(_.raiseLeft)
satorg commented 1 month ago

Perhaps, mouse can help out with that F[Option[...]] case:

import mouse.foption.*

def lookupSomething(id: ID): F[Option[Something]] = ???

val res: F[Something] =
  lookupSomething(id).getOrElseF {
    log(s"nothing found for $id") >>
      F.raiseError(new RuntimeException("not found"))
  }