diff --git a/core/src/main/scala/cats/ApplicativeError.scala b/core/src/main/scala/cats/ApplicativeError.scala index 4c21e72858..e52ef73578 100644 --- a/core/src/main/scala/cats/ApplicativeError.scala +++ b/core/src/main/scala/cats/ApplicativeError.scala @@ -104,6 +104,27 @@ trait ApplicativeError[F[_], E] extends Applicative[F] { */ def handleError[A](fa: F[A])(f: E => A): F[A] = handleErrorWith(fa)(f.andThen(pure)) + /** + * Void any error, by mapping it to `Unit`. + * + * This is useful when errors are reported via a side-channel but not directly handled. + * For example in Cats Effect: + * + * {{{ + * IO.deferred[OutcomeIO[A]].flatMap { oc => + * ioa.guaranteeCase(oc.complete(_).void).void.voidError.start + * // ... + * } + * }}} + * + * Without the `.voidError`, the Cats Effect runtime would consider an error in `ioa` to be + * unhandled and elevate it to [[scala.concurrent.ExecutionContext.reportFailure ExecutionContext#reportFailure]]. + * + * @see [[handleError]] to map to an `A` value instead of `Unit`. + * @see [[https://github.com/typelevel/cats-effect/issues/3152 cats-effect#3152]] + */ + def voidError(fu: F[Unit]): F[Unit] = handleError(fu)(Function.const(())) + /** * Handle errors by turning them into [[scala.util.Either]] values. * diff --git a/core/src/main/scala/cats/syntax/applicativeError.scala b/core/src/main/scala/cats/syntax/applicativeError.scala index ff12673bb6..402c9f719a 100644 --- a/core/src/main/scala/cats/syntax/applicativeError.scala +++ b/core/src/main/scala/cats/syntax/applicativeError.scala @@ -34,6 +34,11 @@ trait ApplicativeErrorSyntax { fa: F[A] )(implicit F: ApplicativeError[F, E]): ApplicativeErrorOps[F, E, A] = new ApplicativeErrorOps[F, E, A](fa) + + implicit final def catsSyntaxApplicativeErrorFUnit[F[_], E]( + fu: F[Unit] + ): ApplicativeErrorFUnitOps[F, E] = + new ApplicativeErrorFUnitOps[F, E](fu) } /** @@ -143,3 +148,8 @@ final class ApplicativeErrorOps[F[_], E, A](private val fa: F[A]) extends AnyVal def orRaise(other: => E)(implicit F: ApplicativeError[F, E]): F[A] = adaptErr { case _ => other } } + +final class ApplicativeErrorFUnitOps[F[_], E](private val fu: F[Unit]) extends AnyVal { + def voidError(implicit F: ApplicativeError[F, E]): F[Unit] = + F.voidError(fu) +} diff --git a/laws/src/main/scala/cats/laws/ApplicativeErrorLaws.scala b/laws/src/main/scala/cats/laws/ApplicativeErrorLaws.scala index c6b21eeef3..180dccaaec 100644 --- a/laws/src/main/scala/cats/laws/ApplicativeErrorLaws.scala +++ b/laws/src/main/scala/cats/laws/ApplicativeErrorLaws.scala @@ -61,6 +61,9 @@ trait ApplicativeErrorLaws[F[_], E] extends ApplicativeLaws[F] { def attemptFromEitherConsistentWithPure[A](eab: Either[E, A]): IsEq[F[Either[E, A]]] = F.attempt(F.fromEither(eab)) <-> F.pure(eab) + def voidErrorConsistentWithHandleError(fu: F[Unit]): IsEq[F[Unit]] = + F.voidError(fu) <-> F.handleError(fu)(Function.const(())) + def onErrorPure[A](a: A, f: E => F[Unit]): IsEq[F[A]] = F.onError(F.pure(a)) { case x => f(x) } <-> F.pure(a) diff --git a/laws/src/main/scala/cats/laws/discipline/ApplicativeErrorTests.scala b/laws/src/main/scala/cats/laws/discipline/ApplicativeErrorTests.scala index 6d0c4fd903..5597fad20a 100644 --- a/laws/src/main/scala/cats/laws/discipline/ApplicativeErrorTests.scala +++ b/laws/src/main/scala/cats/laws/discipline/ApplicativeErrorTests.scala @@ -75,6 +75,11 @@ trait ApplicativeErrorTests[F[_], E] extends ApplicativeTests[F] { "applicativeError attempt fromEither consistent with pure" -> forAll( laws.attemptFromEitherConsistentWithPure[A] _ ), + "applicativeError voidError consistent with void+handleError" -> forAll { (a: A) => + // Should be an implicit parameter but that is not a binary-compatible change + implicit val eqFUnit: Eq[F[Unit]] = makeEqFUnit[A](a) + forAll(laws.voidErrorConsistentWithHandleError _) + }, "applicativeError onError pure" -> forAll(laws.onErrorPure[A] _), "applicativeError onError raise" -> forAll(laws.onErrorRaise[A] _), "applicativeError adaptError pure" -> forAll(laws.adaptErrorPure[A] _), diff --git a/laws/src/main/scala/cats/laws/discipline/ApplicativeTests.scala b/laws/src/main/scala/cats/laws/discipline/ApplicativeTests.scala index fc6ebbbe7a..8ac2829ee2 100644 --- a/laws/src/main/scala/cats/laws/discipline/ApplicativeTests.scala +++ b/laws/src/main/scala/cats/laws/discipline/ApplicativeTests.scala @@ -30,7 +30,7 @@ import org.scalacheck.{Arbitrary, Cogen, Gen} trait ApplicativeTests[F[_]] extends ApplyTests[F] { def laws: ApplicativeLaws[F] - private def makeEqFUnit[A](a: A)(implicit EqFA: Eq[F[A]]): Eq[F[Unit]] = + private[discipline] def makeEqFUnit[A](a: A)(implicit EqFA: Eq[F[A]]): Eq[F[Unit]] = Eq.by(fa => laws.F.as(fa, a)) def applicative[A: Arbitrary, B: Arbitrary, C: Arbitrary](implicit diff --git a/tests/shared/src/test/scala/cats/tests/SyntaxSuite.scala b/tests/shared/src/test/scala/cats/tests/SyntaxSuite.scala index 829577afd5..79abb123d9 100644 --- a/tests/shared/src/test/scala/cats/tests/SyntaxSuite.scala +++ b/tests/shared/src/test/scala/cats/tests/SyntaxSuite.scala @@ -474,6 +474,9 @@ object SyntaxSuite { val ea = mock[E => A] val gea1 = ga.handleError(ea) + val geu = mock[G[Unit]] + val veu = geu.voidError + val egea = mock[E => G[A]] val gea2 = ga.handleErrorWith(egea)