From f91071714db21b87b5af49aa8a37dca29cea7ef5 Mon Sep 17 00:00:00 2001 From: satorg Date: Tue, 3 Feb 2026 23:19:00 -0800 Subject: [PATCH 1/2] add `Option` syntax --- core/src/main/scala/cats/mtl/syntax/all.scala | 1 + .../main/scala/cats/mtl/syntax/option.scala | 106 ++++++++++++++++++ .../test/scala/cats/mtl/tests/Syntax.scala | 14 +++ 3 files changed, 121 insertions(+) create mode 100644 core/src/main/scala/cats/mtl/syntax/option.scala diff --git a/core/src/main/scala/cats/mtl/syntax/all.scala b/core/src/main/scala/cats/mtl/syntax/all.scala index 68e7cd97..7f0990dc 100644 --- a/core/src/main/scala/cats/mtl/syntax/all.scala +++ b/core/src/main/scala/cats/mtl/syntax/all.scala @@ -27,3 +27,4 @@ trait AllSyntax with TellSyntax with HandleSyntax with ChronicleSyntax + with OptionSyntax diff --git a/core/src/main/scala/cats/mtl/syntax/option.scala b/core/src/main/scala/cats/mtl/syntax/option.scala new file mode 100644 index 00000000..f3019854 --- /dev/null +++ b/core/src/main/scala/cats/mtl/syntax/option.scala @@ -0,0 +1,106 @@ +/* + * Copyright 2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cats +package mtl +package syntax + +object option extends OptionSyntax + +private[mtl] trait OptionSyntax { + implicit def catsMtsSyntaxToOptionOps[A](oa: Option[A]): OptionOps[A] = new OptionOps[A](oa) +} + +final class OptionOps[A] private[mtl] (private val self: Option[A]) extends AnyVal { + import OptionOps.* + + /** + * Lifts `Option[A]` to `F[A]` as long as there's `Raise[F, E]` in the scope and `F` is an + * `Applicative`, with `E` used as the error value when the `Option` is empty. + * + * @note + * method `.rescue` in the example requires `ApplicativeError[F, E]` instance. + * + * @example + * (Scala 3) + * {{{ + * import scala.util.* + * import cats.mtl.*, syntax.option.* + * + * case class MyErr(err: String) extends Exception(err) + * + * val res1 = + * Handle.allow: + * Some(123).liftTo[Try]("OOPS") + * .rescue: err => + * Failure(MyErr(err)) + * + * assertEquals(res1, Success(123)) + * + * val res2 = + * Handle.allow: + * None.liftTo[Try]("OOPS") + * .rescue: err => + * Failure(MyErr(err)) + * + * assertEquals(res2, Failure(MyErr("OOPS"))) + * }}} + */ + def liftTo[F[_]]: LiftToPartiallyApplied[F, A] = + new LiftToPartiallyApplied(self) + + /** + * Raises `Option[A]` to `F[Unit]` as an error when present as long as there's `Raise[F, A]` + * in the scope and `F` is an `Applicative` + * + * @note + * method `.rescue` in the example requires `ApplicativeError[F, E]` instance. + * + * @example + * (Scala 3) + * {{{ + * import scala.util.* + * import cats.mtl.*, syntax.option.* + * + * case class MyErr(err: String) extends Exception(err) + * + * val res1 = + * Handle.allow: + * Some("OOPS").raiseTo[Try] + * .rescue: err => + * Failure(MyErr(err)) + * + * assertEquals(res1, Failure(MyErr("OOPS"))) + * + * val res2 = + * Handle.allow: + * (None: Option[String]).raiseTo[Try] + * .rescue: err => + * Failure(MyErr(err)) + * + * assertEquals(res2, Success(())) + * }}} + */ + def raiseTo[F[_]](implicit F: Applicative[F], raise: Raise[F, A]): F[Unit] = + self.fold(F.unit)(raise.raise) +} + +object OptionOps { + final class LiftToPartiallyApplied[F[_], A](private val self: Option[A]) extends AnyVal { + def apply[E](ifEmpty: => E)(implicit F: Applicative[F], raise: Raise[F, E]): F[A] = + raise.fromOption(self)(ifEmpty) + } +} diff --git a/tests/shared/src/test/scala/cats/mtl/tests/Syntax.scala b/tests/shared/src/test/scala/cats/mtl/tests/Syntax.scala index 4acec8db..3f9fa978 100644 --- a/tests/shared/src/test/scala/cats/mtl/tests/Syntax.scala +++ b/tests/shared/src/test/scala/cats/mtl/tests/Syntax.scala @@ -70,6 +70,20 @@ final class Syntax extends BaseSuite { bar[F] *> Bar(42).raise val _ = foo[Either[Foo, *]] + + def fooWithApplicativeError[F[_]](implicit + // `Applicative` is required by `liftTo`. + // `ApplicativeError` is used to enforce checks for non-ambiguity. + F: ApplicativeError[F, Foo], + raise: Raise[F, Foo]): Unit = { + + val _ = ( + Some("abc").liftTo[F](Bar(123)): F[String], + Some(Bar(456)).raiseTo[F]: F[Unit] + ) + } + + fooWithApplicativeError[Either[Foo, *]] } test("Handle") { From 756eaad11bc8dd5a1a1d2b0829dc45cee4c15218 Mon Sep 17 00:00:00 2001 From: satorg Date: Tue, 3 Feb 2026 23:40:02 -0800 Subject: [PATCH 2/2] add `Either` syntax --- core/src/main/scala/cats/mtl/syntax/all.scala | 1 + .../main/scala/cats/mtl/syntax/either.scala | 64 +++++++++++++++++++ .../test/scala/cats/mtl/tests/Syntax.scala | 6 +- 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 core/src/main/scala/cats/mtl/syntax/either.scala diff --git a/core/src/main/scala/cats/mtl/syntax/all.scala b/core/src/main/scala/cats/mtl/syntax/all.scala index 7f0990dc..4f7e9756 100644 --- a/core/src/main/scala/cats/mtl/syntax/all.scala +++ b/core/src/main/scala/cats/mtl/syntax/all.scala @@ -28,3 +28,4 @@ trait AllSyntax with HandleSyntax with ChronicleSyntax with OptionSyntax + with EitherSyntax diff --git a/core/src/main/scala/cats/mtl/syntax/either.scala b/core/src/main/scala/cats/mtl/syntax/either.scala new file mode 100644 index 00000000..ad3b0f2d --- /dev/null +++ b/core/src/main/scala/cats/mtl/syntax/either.scala @@ -0,0 +1,64 @@ +/* + * Copyright 2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cats +package mtl +package syntax + +object either extends EitherSyntax + +private[mtl] trait EitherSyntax { + implicit def catsMtsSyntaxToEitherOps[A, B](oa: Either[A, B]): EitherOps[A, B] = + new EitherOps[A, B](oa) +} + +final class EitherOps[A, B] private[mtl] (private val self: Either[A, B]) extends AnyVal { + + /** + * Lifts `Either[A, B]` to `F[B]` as long as there's `Raise[F, A]` in the scope and `F` is an + * `Applicative`. + * + * @note + * method `.rescue` in the example requires `ApplicativeError[F, E]` instance. + * + * @example + * (Scala 3) + * {{{ + * import scala.util.* + * import cats.mtl.*, syntax.either.* + * + * case class MyErr(err: String) extends Exception(err) + * + * val res1 = + * Handle.allow: + * Right[String, Int](123).liftTo[Try] + * .rescue: err => + * Failure(MyErr(err)) + * + * assertEquals(res1, Success(123)) + * + * val res2 = + * Handle.allow: + * Left[String, Int]("OOPS").liftTo[Try] + * .rescue: err => + * Failure(MyErr(err)) + * + * assertEquals(res2, Failure("OOPS")) + * }}} + */ + def liftTo[F[_]](implicit F: Applicative[F], raise: Raise[F, A]): F[B] = + raise.fromEither(self) +} diff --git a/tests/shared/src/test/scala/cats/mtl/tests/Syntax.scala b/tests/shared/src/test/scala/cats/mtl/tests/Syntax.scala index 3f9fa978..d5193901 100644 --- a/tests/shared/src/test/scala/cats/mtl/tests/Syntax.scala +++ b/tests/shared/src/test/scala/cats/mtl/tests/Syntax.scala @@ -78,8 +78,12 @@ final class Syntax extends BaseSuite { raise: Raise[F, Foo]): Unit = { val _ = ( + // cats.mtl.syntax.option.* Some("abc").liftTo[F](Bar(123)): F[String], - Some(Bar(456)).raiseTo[F]: F[Unit] + Some(Bar(456)).raiseTo[F]: F[Unit], + // cats.mtl.syntax.either.* + Left[Bar, String](Bar(789)).liftTo[F]: F[String], + Right[Bar, String]("def").liftTo[F]: F[String] ) }