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 anif
/else
conditional and usingpure
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 theF
monad, notOption
.
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)