Skip to content

Add attemptOption to Alternative#4862

Open
MavenRain wants to merge 4 commits into
typelevel:mainfrom
MavenRain:attempt-option
Open

Add attemptOption to Alternative#4862
MavenRain wants to merge 4 commits into
typelevel:mainfrom
MavenRain:attempt-option

Conversation

@MavenRain
Copy link
Copy Markdown

@MavenRain MavenRain commented May 16, 2026

Closes #2936.

Adds attemptOption[A](fa: F[A]): F[Option[A]] to Alternative,
implementing the standard optional parser-combinator (Haskell's
Control.Applicative.optional): try fa, surface its result as
Some, and combine with pure(None) so the result always succeeds at
least once.

def attemptOption[A](fa: F[A]): F[Option[A]] =
  combineK(map(fa)((a: A) => Some(a): Option[A]), pure(Option.empty[A]))

  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 typelevel#2936.

Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
@satorg satorg requested review from LukaJCB and satorg May 16, 2026 15:27
@satorg
Copy link
Copy Markdown
Contributor

satorg commented May 16, 2026

@MavenRain , thank you for the PR!

I've got a few comments:

  1. It can be moved to NonEmptyAlternative.
  2. It can use appendK instead of combineK.
  3. It makes sense to add a corresponding rule to NonEmptyAlternativeLaws using the default implementation in the rule body.
  4. Does it have to be *Option only? Does it make sense to make it more generic, e.g.:
    def attemptG[G[_] : Alternative, A]: F[G[A]] = ???
    However, if it does make sense, then it can benefit from the partial-apply technique, so that we could write something like this:
    scala> List(1, 2, 3).attemtG[Option]
    res0: List[Option[Int]] = List(Some(1), Some(2), Some(3), None)
  5. Why attempt name in the first place? Cats already uses attempt for seemingly different purposes.

Comment on lines +89 to +94
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))
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't seem providing any value because the following two property based tests should cover all these cases already.

* }}}
*/
def attemptOption[A](fa: F[A]): F[Option[A]] =
combineK(map(fa)((a: A) => Some(a): Option[A]), pure(Option.empty[A]))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'd rather add:

private val fempty: F[Option[Nothing]] = pure(Option.empty[Nothing])

def attemptOption[A](fa: F[A]): F[Option[A]] =
    combineK(map(fa)((a: A) => Some(a): Option[A]), widen[Option[A]](fempty))

Since widen by default is a cast (and generally 0 cost), this prevents allocating the fempty on every call.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @johnynek. I tried the cached fempty but had to back it out: a cached field (I used a lazy val) materializes on every concrete instance, and several NonEmptyAlternative instances are value classes, e.g. NonEmptySeq is final class ... extends AnyVal, so the extra field breaks their Serializable law tests. Instead I've expressed the method via appendK: on the instances that override appendK (List uses :+, Option short-circuits on isDefined) that avoids constructing pure(None) per call entirely, which gets at your allocation point without a cached field.

MavenRain added 3 commits May 20, 2026 11:34
…rnative + add law

  - Move `attemptOption` from `Alternative` to `NonEmptyAlternative`
    (satorg typelevel#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 typelevel#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 <softwareengineerasaservant@isurvivable.cv>
  scalafmtSbtCheck on the 2.12 / catsJS lane caught a long-line wrap that
  scalafmtCheckAll did not surface locally.

Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
  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 <softwareengineerasaservant@isurvivable.cv>
@MavenRain
Copy link
Copy Markdown
Author

The only red check is the Scala Native job, which died with Test runner interrupted by fatal signal 11 in KleisliSuite. That's a native SIGSEGV, not a test failure, and it's unrelated to this PR (which only touches NonEmptyAlternative, the laws, and tests). The same crash shows up on recent main push runs, e.g. 24011540413 and 26335574733. JVM and JS are all green. Could a maintainer re-run the failed Native job? As a fork PR I can't trigger a re-run myself.

@MavenRain
Copy link
Copy Markdown
Author

@MavenRain , thank you for the PR!

I've got a few comments:

  1. It can be moved to NonEmptyAlternative.

  2. It can use appendK instead of combineK.

  3. It makes sense to add a corresponding rule to NonEmptyAlternativeLaws using the default implementation in the rule body.

  4. Does it have to be *Option only? Does it make sense to make it more generic, e.g.:

    def attemptG[G[_] : Alternative, A]: F[G[A]] = ???

    However, if it does make sense, then it can benefit from the partial-apply technique, so that we could write something like this:

    scala> List(1, 2, 3).attemtG[Option]
    res0: List[Option[Int]] = List(Some(1), Some(2), Some(3), None)
  5. Why attempt name in the first place? Cats already uses attempt for seemingly different purposes.

Good catch on the attempt collision. You're right that it reads like the ApplicativeError.attempt / attemptT / attemptNarrow family, which is unrelated error capture returning Either[E, _]. This is really the parser-combinator optional (Haskell's Control.Applicative.optional, Just <$> fa <|> pure Nothing), which the scaladoc already cites, so I've renamed it to optional. That deviates from the #2936 title @LukaJCB proposed, so flagging in case anyone prefers to keep attemptOption.

@MavenRain
Copy link
Copy Markdown
Author

@satorg On attemptG[G]: I'd like to land the Option-specific version first, since that's what #2936 asks for and it keeps the surface minimal. A generic attemptG[G[_]: Alternative] is a clean, non-breaking superset we can add later (the Option case becomes attemptG[Option]), and the partial-apply ergonomics you sketched deserve their own discussion. I'd be happy to open a follow-up issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add attemptOption to Alternative

3 participants