I found myself in a situation when several Futures had to execute in a predefined sequence and one of those Futures was to be executed only under a certain condition. I immediately reached for the cats-provided flatMap syntax (cats.syntax.flatMap._
) and my pet ifM
:
_ <- condition.pure.ifM(service.call(params), ().pure)
However, the above doesn’t look good. It’s hard to see what’s going on. So I tinkered with cats and made the more pleasing to the eye:
_ <- service.call(params) onlyIf condition
After all,
In the original language design great care was taken to ensure that the syntax would allow programmers to create natural looking DSLs.
www.scala-lang.org
Original solution
import scala.concurrent.ExecutionContext.Implicits.global
import cats.instances.future._
import cats.syntax.applicative._
import cats.syntax.flatMap._
for {
vatEligibility <- viewModel[VatServiceEligibility]
_ <- s4l.saveForm(vatEligibility.setAnswer(question, data.answer))
exit = data.answer == question.exitAnswer
_ <- exit.pure.ifM(keystore.cache(INELIGIBILITY_REASON, question.name), ().pure)
} yield ...
Let’s focus on this line:
exit.pure.ifM(keystore.cache(INELIGIBILITY_REASON, question.name), ().pure)
This lifts the boolean condition exit
into the Future
context using .pure
syntax. pure
method is defined on the Applicative
type class and if we import cats.syntax.applicative._
we can lift any value into an effect F
, provided there is an implicit instance of Applicative[F]
available in scope. (Read Eugene’s article to understand how implicits are resolved)
We can make further use of the cats library to enrich any Future[Boolean]
(indeed, a boolean in any monadic context) with the ifM
method. ifM
is introduced by the import of cats.syntax.flatMap._
and allows for flatMapping different expressions, depending on what’s in the box (i.e. the boolean value in the context, on which we added the ifM
extension method).
Just like flatMap
takes a function A => F[B]
, ifM
takes two functions of this shape, but only flatMaps one of them. The first parameter to ifM
is called ifTrue
and the second one is ifFalse
and which one gets flatmapped is obvious. I made the conditional service call in the ifTrue
part, leaving the ifFalse
as a successfully completed Future
of Unit
.
But when I slept on it and re-visited the line the next day, I thought I would much rather have it written like this:
_ <- keystore.cache(INELIGIBILITY_REASON, question.name) onlyIf exit
Natural looking DSL
Remember the part about natural looking DSLs? With the help of an implicit class and some cat-herding skills, I was able to make my dream come true:
implicit class CustomApplicativeOps[F[_], A](fa: => F[A])(implicit F: Applicative[F]) {
def onlyIf(condition: Boolean): F[Unit] =
if (condition) F.map(fa)(_ => ()) else F.pure(())
}
Final solution
With this implicit class imported into scope, the final for-comprehension looks like this:
import scala.concurrent.ExecutionContext.Implicits.global
import cats.instances.future._
import cats.syntax.applicative._
import cats.syntax.flatMap._
for {
vatEligibility <- viewModel[VatServiceEligibility]
_ <- s4l.saveForm(vatEligibility.setAnswer(question, data.answer))
exit = data.answer == question.exitAnswer
_ <- keystore.cache(INELIGIBILITY_REASON, question.name) onlyIf exit
} yield ...
And that’s all, folks.
PS: cats will provide whenA
syntax from version 1.0. Check it out when it comes out. It can be used to achieve similar sort of thing.