From ee543bb14b0d28e387d038dd04c4c64c59d56849 Mon Sep 17 00:00:00 2001 From: Onyeka Obi Date: Sat, 16 May 2026 04:47:38 -0700 Subject: [PATCH 1/4] Add `attemptOption` to Alternative Implements the standard `optional` parser-combinator (Haskell's `Control.Applicative.optional`): lift a possibly-empty F[A] into an always-non-empty F[Option[A]] by combining `map(_.some)` with `pure(None)` via `combineK`. Added to the `Alternative` trait with a default implementation and to the simulacrum-style `Ops` trait so it is reachable via `import cats.syntax.alternative._`. Closes #2936. Signed-off-by: Onyeka Obi --- core/src/main/scala/cats/Alternative.scala | 31 +++++++++++++++++++ .../scala/cats/tests/AlternativeSuite.scala | 21 +++++++++++++ 2 files changed, 52 insertions(+) diff --git a/core/src/main/scala/cats/Alternative.scala b/core/src/main/scala/cats/Alternative.scala index 2cba69e1f3..e05719427d 100644 --- a/core/src/main/scala/cats/Alternative.scala +++ b/core/src/main/scala/cats/Alternative.scala @@ -115,6 +115,36 @@ trait Alternative[F[_]] extends NonEmptyAlternative[F] with MonoidK[F] { self => def guard(condition: Boolean): F[Unit] = if (condition) unit else empty + /** + * Lift `fa` from `F[A]` into `F[Option[A]]` by surfacing every `a` that `fa` produces as + * `Some(a)` and combining (via `combineK`) with a `pure(None)` so that `attemptOption(fa)` + * always succeeds at least once, with the additional `None` witnessing the possibility + * that `fa` produced no values. + * + * This is the standard `optional` combinator from parser-combinator libraries and matches + * Haskell's `Control.Applicative.optional`: `Just <$> fa <|> pure Nothing`. Note that for + * non-deterministic instances such as `List`, `attemptOption` always appends an extra + * `None`, which is consistent with the Alternative laws but may look surprising at first + * glance. + * + * Example: + * {{{ + * scala> Alternative[Option].attemptOption(Option(5)) + * res0: Option[Option[Int]] = Some(Some(5)) + * + * scala> Alternative[Option].attemptOption(Option.empty[Int]) + * res1: Option[Option[Int]] = Some(None) + * + * scala> Alternative[List].attemptOption(List(1, 2, 3)) + * res2: List[Option[Int]] = List(Some(1), Some(2), Some(3), None) + * + * scala> Alternative[List].attemptOption(List.empty[Int]) + * res3: List[Option[Int]] = List(None) + * }}} + */ + def attemptOption[A](fa: F[A]): F[Option[A]] = + combineK(map(fa)((a: A) => Some(a): Option[A]), pure(Option.empty[A])) + override def compose[G[_]: Applicative]: Alternative[λ[α => F[G[α]]]] = new ComposedAlternative[F, G] { val F = self @@ -158,6 +188,7 @@ object Alternative { typeClassInstance.separate[G, B, C](self.asInstanceOf[F[G[B, C]]]) def separateFoldable[G[_, _], B, C](implicit ev$1: A <:< G[B, C], G: Bifoldable[G], FF: Foldable[F]): (F[B], F[C]) = typeClassInstance.separateFoldable[G, B, C](self.asInstanceOf[F[G[B, C]]])(G, FF) + def attemptOption: F[Option[A]] = typeClassInstance.attemptOption[A](self) } trait AllOps[F[_], A] extends Ops[F, A] with NonEmptyAlternative.AllOps[F, A] with MonoidK.AllOps[F, A] { type TypeClassType <: Alternative[F] diff --git a/tests/shared/src/test/scala/cats/tests/AlternativeSuite.scala b/tests/shared/src/test/scala/cats/tests/AlternativeSuite.scala index 3a4264fdd9..ba2dac1e8f 100644 --- a/tests/shared/src/test/scala/cats/tests/AlternativeSuite.scala +++ b/tests/shared/src/test/scala/cats/tests/AlternativeSuite.scala @@ -85,4 +85,25 @@ class AlternativeSuite extends CatsSuite { assert(Alternative[Option].guard(true).isDefined) assert(Alternative[Option].guard(false).isEmpty) } + + test("attemptOption") { + assert(Alternative[Option].attemptOption(Option(5)) === Some(Some(5))) + assert(Alternative[Option].attemptOption(Option.empty[Int]) === Some(None)) + assert(Alternative[List].attemptOption(List(1, 2, 3)) === List(Some(1), Some(2), Some(3), None)) + assert(Alternative[List].attemptOption(List.empty[Int]) === List(None)) + } + + property("attemptOption is map(Some) combineK pure(None) for List") { + forAll { (xs: List[Int]) => + val expected: List[Option[Int]] = xs.map(Some(_)) :+ None + assert(Alternative[List].attemptOption(xs) === expected) + } + } + + property("attemptOption on Option preserves Some, surfaces None as Some(None)") { + forAll { (o: Option[Int]) => + val expected: Option[Option[Int]] = o.fold[Option[Option[Int]]](Some(None))(a => Some(Some(a))) + assert(Alternative[Option].attemptOption(o) === expected) + } + } } From 02d5ac3e8baac12b444f720bf96240dac59adad3 Mon Sep 17 00:00:00 2001 From: Onyeka Obi Date: Wed, 20 May 2026 11:34:34 -0700 Subject: [PATCH 2/4] Address review on #4862: move attemptOption to NonEmptyAlternative + add law - Move `attemptOption` from `Alternative` to `NonEmptyAlternative` (satorg #1). Implementation only needs `combineK + map + pure`. - Use `widen(fempty)` with a cached `F[Option[Nothing]]` to avoid allocating a fresh `pure(None)` on every call (johnynek nit). - Add `nonEmptyAlternativeAttemptOptionConsistentWithCombineKAndPure` to `NonEmptyAlternativeLaws` and wire it into the `nonEmptyAlternative` rule set (satorg #3). This required adding `EqFOA: Eq[F[Option[A]]]` to the `nonEmptyAlternative` and `alternative` rule sets' implicit parameter lists. - Drop the redundant point test in `AlternativeSuite` (satorg inline). - Move the two `attemptOption` property tests to `NonEmptyAlternativeSuite` to follow the method's new location. - Add two `mima.sbt` exclusions for the test-helper signature change. Signed-off-by: Onyeka Obi --- core/src/main/scala/cats/Alternative.scala | 31 ---------------- .../main/scala/cats/NonEmptyAlternative.scala | 36 +++++++++++++++++++ .../cats/laws/NonEmptyAlternativeLaws.scala | 3 ++ .../laws/discipline/AlternativeTests.scala | 1 + .../discipline/NonEmptyAlternativeTests.scala | 5 ++- mima.sbt | 6 ++++ .../scala/cats/tests/AlternativeSuite.scala | 21 ----------- .../cats/tests/NonEmptyAlternativeSuite.scala | 16 +++++++++ 8 files changed, 66 insertions(+), 53 deletions(-) diff --git a/core/src/main/scala/cats/Alternative.scala b/core/src/main/scala/cats/Alternative.scala index e05719427d..2cba69e1f3 100644 --- a/core/src/main/scala/cats/Alternative.scala +++ b/core/src/main/scala/cats/Alternative.scala @@ -115,36 +115,6 @@ trait Alternative[F[_]] extends NonEmptyAlternative[F] with MonoidK[F] { self => def guard(condition: Boolean): F[Unit] = if (condition) unit else empty - /** - * Lift `fa` from `F[A]` into `F[Option[A]]` by surfacing every `a` that `fa` produces as - * `Some(a)` and combining (via `combineK`) with a `pure(None)` so that `attemptOption(fa)` - * always succeeds at least once, with the additional `None` witnessing the possibility - * that `fa` produced no values. - * - * This is the standard `optional` combinator from parser-combinator libraries and matches - * Haskell's `Control.Applicative.optional`: `Just <$> fa <|> pure Nothing`. Note that for - * non-deterministic instances such as `List`, `attemptOption` always appends an extra - * `None`, which is consistent with the Alternative laws but may look surprising at first - * glance. - * - * Example: - * {{{ - * scala> Alternative[Option].attemptOption(Option(5)) - * res0: Option[Option[Int]] = Some(Some(5)) - * - * scala> Alternative[Option].attemptOption(Option.empty[Int]) - * res1: Option[Option[Int]] = Some(None) - * - * scala> Alternative[List].attemptOption(List(1, 2, 3)) - * res2: List[Option[Int]] = List(Some(1), Some(2), Some(3), None) - * - * scala> Alternative[List].attemptOption(List.empty[Int]) - * res3: List[Option[Int]] = List(None) - * }}} - */ - def attemptOption[A](fa: F[A]): F[Option[A]] = - combineK(map(fa)((a: A) => Some(a): Option[A]), pure(Option.empty[A])) - override def compose[G[_]: Applicative]: Alternative[λ[α => F[G[α]]]] = new ComposedAlternative[F, G] { val F = self @@ -188,7 +158,6 @@ object Alternative { typeClassInstance.separate[G, B, C](self.asInstanceOf[F[G[B, C]]]) def separateFoldable[G[_, _], B, C](implicit ev$1: A <:< G[B, C], G: Bifoldable[G], FF: Foldable[F]): (F[B], F[C]) = typeClassInstance.separateFoldable[G, B, C](self.asInstanceOf[F[G[B, C]]])(G, FF) - def attemptOption: F[Option[A]] = typeClassInstance.attemptOption[A](self) } trait AllOps[F[_], A] extends Ops[F, A] with NonEmptyAlternative.AllOps[F, A] with MonoidK.AllOps[F, A] { type TypeClassType <: Alternative[F] diff --git a/core/src/main/scala/cats/NonEmptyAlternative.scala b/core/src/main/scala/cats/NonEmptyAlternative.scala index a5d846c30f..e1e69abc4e 100644 --- a/core/src/main/scala/cats/NonEmptyAlternative.scala +++ b/core/src/main/scala/cats/NonEmptyAlternative.scala @@ -45,6 +45,41 @@ trait NonEmptyAlternative[F[_]] extends Applicative[F] with SemigroupK[F] { self */ def appendK[A](fa: F[A], a: A): F[A] = combineK(fa, pure(a)) + // Cached `F[Option[Nothing]]` reused by `attemptOption`. Widening to + // `F[Option[A]]` is a zero-cost cast (Option is covariant in its element), + // which avoids allocating a fresh `pure(None)` on every call. + private lazy val fempty: F[Option[Nothing]] = pure(Option.empty[Nothing]) + + /** + * Lift `fa` from `F[A]` into `F[Option[A]]` by surfacing every value `fa` + * produces as `Some(a)` and combining (via `combineK`) with `pure(None)`, + * so the result always succeeds at least once. The additional `None` + * witnesses the possibility that `fa` produced no values. + * + * This is the standard `optional` combinator from parser-combinator + * libraries and matches Haskell's `Control.Applicative.optional`: + * `Just <$> fa <|> pure Nothing`. For non-deterministic instances such + * as `List`, `attemptOption` always appends an extra `None`, which is + * consistent with the laws even if it can look surprising at first. + * + * Example: + * {{{ + * scala> NonEmptyAlternative[Option].attemptOption(Option(5)) + * res0: Option[Option[Int]] = Some(Some(5)) + * + * scala> NonEmptyAlternative[Option].attemptOption(Option.empty[Int]) + * res1: Option[Option[Int]] = Some(None) + * + * scala> NonEmptyAlternative[List].attemptOption(List(1, 2, 3)) + * res2: List[Option[Int]] = List(Some(1), Some(2), Some(3), None) + * + * scala> NonEmptyAlternative[List].attemptOption(List.empty[Int]) + * res3: List[Option[Int]] = List(None) + * }}} + */ + def attemptOption[A](fa: F[A]): F[Option[A]] = + combineK(map(fa)((a: A) => Some(a): Option[A]), widen[Option[Nothing], Option[A]](fempty)) + override def compose[G[_]: Applicative]: NonEmptyAlternative[λ[α => F[G[α]]]] = new ComposedNonEmptyAlternative[F, G] { val F = self @@ -75,6 +110,7 @@ object NonEmptyAlternative { val typeClassInstance: TypeClassType def prependK(a: A): F[A] = typeClassInstance.prependK[A](a, self) def appendK(a: A): F[A] = typeClassInstance.appendK[A](self, a) + def attemptOption: F[Option[A]] = typeClassInstance.attemptOption[A](self) } trait AllOps[F[_], A] extends Ops[F, A] with Applicative.AllOps[F, A] with SemigroupK.AllOps[F, A] { type TypeClassType <: NonEmptyAlternative[F] diff --git a/laws/src/main/scala/cats/laws/NonEmptyAlternativeLaws.scala b/laws/src/main/scala/cats/laws/NonEmptyAlternativeLaws.scala index 8dcd785fbe..aece8866c0 100644 --- a/laws/src/main/scala/cats/laws/NonEmptyAlternativeLaws.scala +++ b/laws/src/main/scala/cats/laws/NonEmptyAlternativeLaws.scala @@ -40,6 +40,9 @@ trait NonEmptyAlternativeLaws[F[_]] extends ApplicativeLaws[F] with SemigroupKLa def nonEmptyAlternativeAppendKConsistentWithPureAndCombineK[A](fa: F[A], a: A): IsEq[F[A]] = fa.appendK(a) <-> (fa <+> a.pure[F]) + def nonEmptyAlternativeAttemptOptionConsistentWithCombineKAndPure[A](fa: F[A]): IsEq[F[Option[A]]] = + F.attemptOption(fa) <-> (fa.map(Some(_): Option[A]) <+> Option.empty[A].pure[F]) + @deprecated("typo in the name, use nonEmptyAlternativePrependKConsistentWithPureAndCombineK instead", "2.14.0") private[laws] def nonEmptyAlternativePrependKConsitentWithPureAndCombineK[A](fa: F[A], a: A): IsEq[F[A]] = nonEmptyAlternativePrependKConsistentWithPureAndCombineK(fa, a) diff --git a/laws/src/main/scala/cats/laws/discipline/AlternativeTests.scala b/laws/src/main/scala/cats/laws/discipline/AlternativeTests.scala index 38feae4565..3af7a63621 100644 --- a/laws/src/main/scala/cats/laws/discipline/AlternativeTests.scala +++ b/laws/src/main/scala/cats/laws/discipline/AlternativeTests.scala @@ -42,6 +42,7 @@ trait AlternativeTests[F[_]] extends NonEmptyAlternativeTests[F] with MonoidKTes EqFA: Eq[F[A]], EqFB: Eq[F[B]], EqFC: Eq[F[C]], + EqFOA: Eq[F[Option[A]]], EqFABC: Eq[F[(A, B, C)]], iso: Isomorphisms[F] ): RuleSet = diff --git a/laws/src/main/scala/cats/laws/discipline/NonEmptyAlternativeTests.scala b/laws/src/main/scala/cats/laws/discipline/NonEmptyAlternativeTests.scala index b6342ff994..504579a347 100644 --- a/laws/src/main/scala/cats/laws/discipline/NonEmptyAlternativeTests.scala +++ b/laws/src/main/scala/cats/laws/discipline/NonEmptyAlternativeTests.scala @@ -42,6 +42,7 @@ trait NonEmptyAlternativeTests[F[_]] extends ApplicativeTests[F] with SemigroupK EqFA: Eq[F[A]], EqFB: Eq[F[B]], EqFC: Eq[F[C]], + EqFOA: Eq[F[Option[A]]], EqFABC: Eq[F[(A, B, C)]], iso: Isomorphisms[F] ): RuleSet = @@ -55,7 +56,9 @@ trait NonEmptyAlternativeTests[F[_]] extends ApplicativeTests[F] with SemigroupK "prependK consistent with pure and combineK" -> forAll(laws.nonEmptyAlternativePrependKConsistentWithPureAndCombineK[A] _), "appendK consistent with pure and combineK" -> - forAll(laws.nonEmptyAlternativeAppendKConsistentWithPureAndCombineK[A] _) + forAll(laws.nonEmptyAlternativeAppendKConsistentWithPureAndCombineK[A] _), + "attemptOption consistent with combineK and pure" -> + forAll(laws.nonEmptyAlternativeAttemptOptionConsistentWithCombineKAndPure[A] _) ) } } diff --git a/mima.sbt b/mima.sbt index af38fe3d6e..042ff7c847 100644 --- a/mima.sbt +++ b/mima.sbt @@ -167,5 +167,11 @@ ThisBuild / mimaBinaryIssueFilters ++= Seq.concat( ProblemFilters.exclude[MissingTypesProblem]("cats.free.FreeFoldable"), ProblemFilters.exclude[IncompatibleTemplateDefProblem]("cats.RepresentableBimonad"), ProblemFilters.exclude[IncompatibleTemplateDefProblem]("cats.RepresentableMonad") + ), + Seq( // PR#4862 (issue #2936): added EqFOA: Eq[F[Option[A]]] implicit param to + // (NonEmpty)AlternativeTests rule sets so the new attemptOption law can run. + // Test-helper signatures only; source-compatible since callers pass implicits. + ProblemFilters.exclude[DirectMissingMethodProblem]("cats.laws.discipline.AlternativeTests.alternative"), + ProblemFilters.exclude[DirectMissingMethodProblem]("cats.laws.discipline.NonEmptyAlternativeTests.nonEmptyAlternative") ) ) diff --git a/tests/shared/src/test/scala/cats/tests/AlternativeSuite.scala b/tests/shared/src/test/scala/cats/tests/AlternativeSuite.scala index ba2dac1e8f..3a4264fdd9 100644 --- a/tests/shared/src/test/scala/cats/tests/AlternativeSuite.scala +++ b/tests/shared/src/test/scala/cats/tests/AlternativeSuite.scala @@ -85,25 +85,4 @@ class AlternativeSuite extends CatsSuite { assert(Alternative[Option].guard(true).isDefined) assert(Alternative[Option].guard(false).isEmpty) } - - test("attemptOption") { - assert(Alternative[Option].attemptOption(Option(5)) === Some(Some(5))) - assert(Alternative[Option].attemptOption(Option.empty[Int]) === Some(None)) - assert(Alternative[List].attemptOption(List(1, 2, 3)) === List(Some(1), Some(2), Some(3), None)) - assert(Alternative[List].attemptOption(List.empty[Int]) === List(None)) - } - - property("attemptOption is map(Some) combineK pure(None) for List") { - forAll { (xs: List[Int]) => - val expected: List[Option[Int]] = xs.map(Some(_)) :+ None - assert(Alternative[List].attemptOption(xs) === expected) - } - } - - property("attemptOption on Option preserves Some, surfaces None as Some(None)") { - forAll { (o: Option[Int]) => - val expected: Option[Option[Int]] = o.fold[Option[Option[Int]]](Some(None))(a => Some(Some(a))) - assert(Alternative[Option].attemptOption(o) === expected) - } - } } diff --git a/tests/shared/src/test/scala/cats/tests/NonEmptyAlternativeSuite.scala b/tests/shared/src/test/scala/cats/tests/NonEmptyAlternativeSuite.scala index d86c0b23b7..dd4b3124aa 100644 --- a/tests/shared/src/test/scala/cats/tests/NonEmptyAlternativeSuite.scala +++ b/tests/shared/src/test/scala/cats/tests/NonEmptyAlternativeSuite.scala @@ -23,6 +23,8 @@ package cats.tests import cats.NonEmptyAlternative import cats.laws.discipline.NonEmptyAlternativeTests +import cats.syntax.eq.* +import org.scalacheck.Prop.* class NonEmptyAlternativeSuite extends CatsSuite { implicit val listWrapperNeAlternative: NonEmptyAlternative[ListWrapper] = ListWrapper.nonEmptyAlternative @@ -42,4 +44,18 @@ class NonEmptyAlternativeSuite extends CatsSuite { "compose ListWrapper[ListWrapper[Int]]", NonEmptyAlternativeTests.composed[ListWrapper, ListWrapper].nonEmptyAlternative[Int, Int, Int] ) + + property("attemptOption on List concatenates map(Some) with pure(None)") { + forAll { (xs: List[Int]) => + val expected: List[Option[Int]] = xs.map(Some(_)) :+ None + assert(NonEmptyAlternative[List].attemptOption(xs) === expected) + } + } + + property("attemptOption on Option preserves Some, surfaces empty as Some(None)") { + forAll { (o: Option[Int]) => + val expected: Option[Option[Int]] = o.fold[Option[Option[Int]]](Some(None))(a => Some(Some(a))) + assert(NonEmptyAlternative[Option].attemptOption(o) === expected) + } + } } From c03b6b61420a5cc078c5071c69c763a8191eaaa2 Mon Sep 17 00:00:00 2001 From: Onyeka Obi Date: Thu, 21 May 2026 06:04:31 -0700 Subject: [PATCH 3/4] Apply scalafmtSbt to mima.sbt scalafmtSbtCheck on the 2.12 / catsJS lane caught a long-line wrap that scalafmtCheckAll did not surface locally. Signed-off-by: Onyeka Obi --- mima.sbt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mima.sbt b/mima.sbt index 042ff7c847..6558c5b450 100644 --- a/mima.sbt +++ b/mima.sbt @@ -172,6 +172,8 @@ ThisBuild / mimaBinaryIssueFilters ++= Seq.concat( // (NonEmpty)AlternativeTests rule sets so the new attemptOption law can run. // Test-helper signatures only; source-compatible since callers pass implicits. ProblemFilters.exclude[DirectMissingMethodProblem]("cats.laws.discipline.AlternativeTests.alternative"), - ProblemFilters.exclude[DirectMissingMethodProblem]("cats.laws.discipline.NonEmptyAlternativeTests.nonEmptyAlternative") + ProblemFilters.exclude[DirectMissingMethodProblem]( + "cats.laws.discipline.NonEmptyAlternativeTests.nonEmptyAlternative" + ) ) ) From c1272e70fd913d9bbddfb8e9303a891cf4d96bcc Mon Sep 17 00:00:00 2001 From: Onyeka Obi Date: Thu, 21 May 2026 06:43:44 -0700 Subject: [PATCH 4/4] Drop fempty cache; AnyVal F like NonEmptySeq isn't Serializable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `private lazy val fempty: F[Option[Nothing]]` in NonEmptyAlternative materialised as a field on every concrete instance. For `NonEmptySeq` (a `final class … extends AnyVal`) the cached boxed value is not `java.io.Serializable`, which broke SerializableTests for every typeclass instance sharing catsDataInstancesForNonEmptySeqBinCompat1 (Bimonad, Align, NonEmptyAlternative, Semigroup-via-.algebra). Inline `pure(Option.empty[A])` directly; matches the example in the PR description and removes the regression. Signed-off-by: Onyeka Obi --- core/src/main/scala/cats/NonEmptyAlternative.scala | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/core/src/main/scala/cats/NonEmptyAlternative.scala b/core/src/main/scala/cats/NonEmptyAlternative.scala index e1e69abc4e..91503bfd64 100644 --- a/core/src/main/scala/cats/NonEmptyAlternative.scala +++ b/core/src/main/scala/cats/NonEmptyAlternative.scala @@ -45,11 +45,6 @@ trait NonEmptyAlternative[F[_]] extends Applicative[F] with SemigroupK[F] { self */ def appendK[A](fa: F[A], a: A): F[A] = combineK(fa, pure(a)) - // Cached `F[Option[Nothing]]` reused by `attemptOption`. Widening to - // `F[Option[A]]` is a zero-cost cast (Option is covariant in its element), - // which avoids allocating a fresh `pure(None)` on every call. - private lazy val fempty: F[Option[Nothing]] = pure(Option.empty[Nothing]) - /** * Lift `fa` from `F[A]` into `F[Option[A]]` by surfacing every value `fa` * produces as `Some(a)` and combining (via `combineK`) with `pure(None)`, @@ -78,7 +73,7 @@ trait NonEmptyAlternative[F[_]] extends Applicative[F] with SemigroupK[F] { self * }}} */ def attemptOption[A](fa: F[A]): F[Option[A]] = - combineK(map(fa)((a: A) => Some(a): Option[A]), widen[Option[Nothing], Option[A]](fempty)) + combineK(map(fa)((a: A) => Some(a): Option[A]), pure(Option.empty[A])) override def compose[G[_]: Applicative]: NonEmptyAlternative[λ[α => F[G[α]]]] = new ComposedNonEmptyAlternative[F, G] {