Optional Actions

By Adam Rosien on 18 Nov 2019

Sometimes, amongst required actions, we need to optionally do something. In this post we’ll see how we can use cats to concisely handle this situation.

The Basic Scenario

Here’s some code that approximates the basic scenario:

for {
x <- requiredAction()
y = if (x.isAmazing) optionalAction()
} yield x

There’s an immediate issue: we don’t have an else for our if.

Did you also know that this is allowed in Scala? An if without an else returns Unit. *Sigh*.

Let’s complete the expression with a placeholder:

for {
x <- requiredAction()
y = if (x.isAmazing) optionalAction()
else ??? // TODO
} yield x

However, as written, if the optional action is executed, we won’t know if it succeeded or failed. Take a moment to see why…

Avoiding Unexamined Effects

Do you see why the action can fail and we won’t know it? Say the optionalAction returns a Future, then y is assigned that Future. And a Future isn’t the result itself, it merely represents some future value. We need to wait until the Future completes to know if it succeeded or not.

for {
x <- requiredAction()
y = if (x.isAmazing) optionalAction()
else ???
// `y` is the action itself, not the successful value of the action!
} yield x

This is true of any monad we use in our expression. If our actions return Either, a value of type Either doesn’t tell us if it suceeded or failed. We need to know if the value is a Left or a Right. And so on for other monads.

For any monad, how do we know if some action succeeded? If it does succeed, we’ll get a value on the left-hand side of the <- in our for-comprehension:

for {
x <- requiredAction()

// switch from using `=` (value binding) to `<-` (monadic binding)
y <- if (x.isAmazing) optionalAction()
else ???
} yield x

Obligatory reminder: a for-comprehension is just syntactic sugar for
nested flatMap calls. So y <- someAction is really someAction.flatMap(y => ???).

The only way y can have a value is if optionalAction succeeded, or the else clause’s action was successful.

Let’s take care of the else case, when x.isAmazing is false. We need to provide some (monadic) value that has a type compatible with optionalAction, in order for the expression to type check. What’s the type of optionalAction?

Since we want to be monad-agnostic, let’s call the monad F. optionalAction will return an F, and the else branch also needs to return an F. But F is a container type, it holds a value. It doesn’t really matter what our else clause returns, so let’s choose () (“Unit”). We “lift” a Unit into the current monad via pure:

import cats.implicits._ // for extension methods like pure

for {
x <- requiredAction()
y <- if (x.isAmazing) optionalAction()
else ().pure[F] // lift a value into an effect F
} yield x

Do we need y? No! We can “throw it away” by naming it _:

for {
x <- requiredAction()
_ <- if (x.isAmazing) optionalAction()
else ().pure[F]
} yield x

Luckily for us, this “if condition, run the action, else pure Unit” pattern is a helper method named whenA!

import cats.Applicative

for {
x <- requiredAction()
_ <- Applicative[F].whenA(x.isAmazing)(optionalAction())
} yield x

NOTE: There is a form of whenA that acts as an extension method on an effect, but the effect is not lazily evaluated, which may be necessary with effects like Future. So please ignore the above ugliness.

To summarize so far:

  • We need to evaluate optional effects, otherwise we won’t know if they succeeded or failed.
  • If we have a boolean condition, we can use the whenA combinator to choose between an optional action and an “empty” effect that returns ().

Optional Actions via the Option type

There’s an alternate way of encoding optional actions, let’s use the Option type! Instead of checking a Boolean value to conditionally evaluate an action of type F[A], what if the action itself was contained within an Option? Then it’s optional, right?

for {
x <- requiredAction()
optA = // has type Option[F[Unit]]
if (x.isAmazing) Some(optionalAction())
else None
// TODO: extract result of opt
} yield x

We could use the Option.when factory method to replace the if (predicate) Some(action)/else None code:

for {
x <- requiredAction()
optA = Option.when(x.isAmazing)(optionalAction())
// TODO: extract result of opt
} yield x

We have a value of type Option[F[Unit]], but our expression requires actions to be in the F monad. Is there a way to transform an Option of F into an F of Option? Yes! That’s sequence (which is equivalent to traverse):

import cats.implicits._

for {
x <- requiredAction()
optA = Option.when(x.isAmazing)(optionalAction())
_ <- optA.sequence // Option[F[Unit]] => F[Option[Unit]]
} yield x

traverse is everywhere!

WARNING: Putting a Future within an Option doesn’t optionally execute the Future, because Future is eagerly scheduled. So you can’t use this technique with Future. Use cats.effect.IO instead.

Conclusions

Optionally performing actions is a common need, and they have a regular structure we can abstract over using cats:

  • Applicative.whenA lets us construct an optional action from a predicate and a way to construct that action. It’s built from an if/else conditional and using pure to construct an empty action in case the predicate fails.
  • Traverse.sequence lets us transform an optional action (Option[F[A]]) into an action that produces an optional value (F[Option[A]]), because our for-comprehension is using the F monad, not Option.

At the same time, we need to avoid pitfalls like constructing optional actions without ensuring they are a part of the entire computation. We also showed how we don’t necessarily care about the value produced by an optional action, only that the action succeeded. In a for-comprehension, this means:

  • evaluating the action with <-; and
  • naming the result _ (to forget it)